fix(cordn): back off on relay pressure and surface relay health
This commit is contained in:
parent
26a8c5eb31
commit
7791cbe9fa
4 changed files with 114 additions and 15 deletions
|
|
@ -47,6 +47,26 @@ disk=$(df --output=pcent / | tail -1 | tr -d ' %')
|
|||
avail=$(free -m | awk '/Mem:/{print $7}')
|
||||
[ "${avail:-0}" -lt 300 ] && ISSUES+=("low memory: ${avail} MiB available")
|
||||
|
||||
# Cordn/Staircase RPC health. Newlay logs one warning per rejected request, so
|
||||
# a burst is easy to miss in routine log noise. Surface it as an operational
|
||||
# problem before users experience a stuck private chat.
|
||||
cordn_rejects=$(docker logs --since 15m newlay-newlay-1 2>&1 | grep -c 'cordn rate limit' || true)
|
||||
[ "${cordn_rejects:-0}" -ge 40 ] && ISSUES+=("cordn rate limits: ${cordn_rejects} rejects in 15m")
|
||||
|
||||
if ! docker logs --since 20m newlay-newlay-1 2>&1 | grep -q 'cordn announce'; then
|
||||
ISSUES+=("cordn coordinator has not announced in 20m")
|
||||
fi
|
||||
|
||||
# TLS expiry (Caddy auto-renews; this catches a broken renewal early).
|
||||
expiry=$(echo | openssl s_client -servername relay.skate-spots.org -connect relay.skate-spots.org:443 2>/dev/null \
|
||||
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
if [ -n "$expiry" ]; then
|
||||
days=$(( ( $(date -d "$expiry" +%s) - $(date +%s) ) / 86400 ))
|
||||
[ "$days" -lt 14 ] && ISSUES+=("relay TLS expires in ${days}d")
|
||||
else
|
||||
ISSUES+=("could not read relay TLS certificate")
|
||||
fi
|
||||
|
||||
sig=""
|
||||
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
|
||||
prev=""
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
||||
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
||||
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
SKATESPOTS_COORDINATOR,
|
||||
} from '@/lib/coordinator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { urlDomain } from '@/lib/sanitizeUrl';
|
||||
|
||||
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
|
||||
const s = Math.floor(Date.now() / 1000 - unixSeconds);
|
||||
|
|
@ -59,6 +61,28 @@ async function copyText(text: string, copiedText: string, failedTitle: string, f
|
|||
}
|
||||
}
|
||||
|
||||
/** Small live health pill for one relay. Probes only while this card is open. */
|
||||
function RelayHealthPill({ url }: { url: string }) {
|
||||
const { status, reason, recheck } = useRelayStatus(url);
|
||||
const dot =
|
||||
status === 'connected'
|
||||
? 'bg-green-400'
|
||||
: status === 'connecting'
|
||||
? 'bg-blue-400 animate-pulse'
|
||||
: 'bg-red-400';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={recheck}
|
||||
title={reason ?? status}
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-2 py-1 text-[10px] text-gray-400 transition-colors hover:border-white/20 hover:text-gray-200"
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', dot)} />
|
||||
<span className="truncate">{urlDomain(url)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Coordinator.
|
||||
*
|
||||
|
|
@ -119,6 +143,11 @@ export function CoordinatorSettingsCard() {
|
|||
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
|
||||
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
|
||||
</p>
|
||||
{coordinator.relays.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{coordinator.relays.map((url) => <RelayHealthPill key={url} url={url} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users } from 'lucide-react';
|
||||
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users, QrCode } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -217,6 +219,7 @@ export function CrewCoordinatorCard({
|
|||
function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays?: string[] } }) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
const nprofile = coordinator.pubkey
|
||||
? nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] })
|
||||
: null;
|
||||
|
|
@ -247,6 +250,7 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
|
|||
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openInCordnWeb')}
|
||||
</a>
|
||||
{nprofile && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyNprofile()}
|
||||
|
|
@ -254,6 +258,23 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
|
|||
>
|
||||
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQr((v) => !v)}
|
||||
aria-expanded={showQr}
|
||||
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" /> QR
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showQr && nprofile && (
|
||||
<div className="mt-2 flex w-full flex-col items-center gap-2 rounded-xl border border-white/10 bg-black/40 p-3">
|
||||
<QRCodeCanvas value={nprofile} size={168} level="M" />
|
||||
<p className="text-center text-[11px] text-gray-500">
|
||||
{t('socialUi.coordinator.scanToJoin')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export interface UseCordnNativeResult {
|
|||
/** Send a read receipt for an inbound message. */
|
||||
markRead: (messageId: string) => Promise<boolean>;
|
||||
/** Re-fetch messages (and adopt any new commits). */
|
||||
refresh: () => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
/** Founder/admin: create the crew's MLS group. */
|
||||
createChat: () => Promise<boolean>;
|
||||
/** Non-admin: ask the coordinator to be added (publishes a key package). */
|
||||
|
|
@ -110,6 +110,8 @@ export interface UseCordnNativeResult {
|
|||
}
|
||||
|
||||
const POLL_MS = 6000;
|
||||
const HIDDEN_POLL_MS = 12000;
|
||||
const MAX_BACKOFF_MS = 60000;
|
||||
|
||||
/**
|
||||
* Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn
|
||||
|
|
@ -231,9 +233,9 @@ export function useCordnNative({
|
|||
};
|
||||
}, [enabled, pubkey, signer, coordinator, groupId, reloadKey]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const refresh = useCallback(async (): Promise<boolean> => {
|
||||
const session = sessionRef.current;
|
||||
if (!session || !group) return;
|
||||
if (!session || !group) return false;
|
||||
try {
|
||||
await session.fetch(group.gid);
|
||||
const next = session.messages(group.gid);
|
||||
|
|
@ -241,20 +243,47 @@ export function useCordnNative({
|
|||
setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group);
|
||||
setError(null);
|
||||
setLastCheckedAt(Date.now());
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setLastCheckedAt(Date.now());
|
||||
return false;
|
||||
}
|
||||
}, [group]);
|
||||
|
||||
// Live polling for silent signers only — an interactive signer would pop for
|
||||
// every request, so those users refresh/send manually (same rule as the DM
|
||||
// and sealed-relay chat hooks).
|
||||
// and sealed-relay chat hooks). Hidden tabs poll less, and a failure backs
|
||||
// off exponentially with jitter so a busy/rate-limited coordinator is not
|
||||
// hammered by every open device.
|
||||
const pollDelayRef = useRef(POLL_MS);
|
||||
useEffect(() => {
|
||||
if (!ready || !group || interactive || !canBackgroundPoll) return;
|
||||
const id = setInterval(() => void refresh(), POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [ready, group, interactive, canBackgroundPoll, refresh]);
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS;
|
||||
|
||||
const tick = async () => {
|
||||
const ok = await refresh();
|
||||
if (cancelled) return;
|
||||
if (ok) {
|
||||
pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS;
|
||||
} else {
|
||||
pollDelayRef.current = Math.min(
|
||||
MAX_BACKOFF_MS,
|
||||
Math.max(POLL_MS, Math.round(pollDelayRef.current * 1.7)),
|
||||
);
|
||||
}
|
||||
const jitter = Math.round(Math.random() * 800);
|
||||
timer = setTimeout(() => void tick(), pollDelayRef.current + jitter);
|
||||
};
|
||||
|
||||
timer = setTimeout(() => void tick(), pollDelayRef.current);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [ready, group, interactive, canBackgroundPoll, active, refresh]);
|
||||
|
||||
// One coordinator operation at a time. With Amber every coordinator request
|
||||
// is a foreground approval; a double-tap on "Set up" used to start two
|
||||
|
|
|
|||
Loading…
Reference in a new issue