fix(cordn): back off on relay pressure and surface relay health

This commit is contained in:
SkateSpots 2026-10-30 17:35:59 +07:00
parent 26a8c5eb31
commit 7791cbe9fa
4 changed files with 114 additions and 15 deletions

View file

@ -47,6 +47,26 @@ disk=$(df --output=pcent / | tail -1 | tr -d ' %')
avail=$(free -m | awk '/Mem:/{print $7}') avail=$(free -m | awk '/Mem:/{print $7}')
[ "${avail:-0}" -lt 300 ] && ISSUES+=("low memory: ${avail} MiB available") [ "${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="" sig=""
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|') [ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
prev="" prev=""

View file

@ -13,6 +13,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { QRCodeCanvas } from '@/components/ui/qrcode'; import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useRelayStatus } from '@/hooks/useRelayStatus';
import { QRScannerModal } from '@/components/wallet/QRScannerModal'; import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator'; import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -27,6 +28,7 @@ import {
SKATESPOTS_COORDINATOR, SKATESPOTS_COORDINATOR,
} from '@/lib/coordinator'; } from '@/lib/coordinator';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { urlDomain } from '@/lib/sanitizeUrl';
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string { function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
const s = Math.floor(Date.now() / 1000 - unixSeconds); 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. * Settings → Coordinator.
* *
@ -119,6 +143,11 @@ export function CoordinatorSettingsCard() {
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })} : t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''} {lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
</p> </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> </div>
<span <span
className={cn( className={cn(

View file

@ -1,7 +1,9 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@ -217,6 +219,7 @@ export function CrewCoordinatorCard({
function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays?: string[] } }) { function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays?: string[] } }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const [showQr, setShowQr] = useState(false);
const nprofile = coordinator.pubkey const nprofile = coordinator.pubkey
? nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] }) ? nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] })
: null; : null;
@ -247,6 +250,7 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openInCordnWeb')} <ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openInCordnWeb')}
</a> </a>
{nprofile && ( {nprofile && (
<>
<button <button
type="button" type="button"
onClick={() => void copyNprofile()} 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')} <Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
</button> </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>
)} )}
</> </>
); );

View file

@ -71,7 +71,7 @@ export interface UseCordnNativeResult {
/** Send a read receipt for an inbound message. */ /** Send a read receipt for an inbound message. */
markRead: (messageId: string) => Promise<boolean>; markRead: (messageId: string) => Promise<boolean>;
/** Re-fetch messages (and adopt any new commits). */ /** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>; refresh: () => Promise<boolean>;
/** Founder/admin: create the crew's MLS group. */ /** Founder/admin: create the crew's MLS group. */
createChat: () => Promise<boolean>; createChat: () => Promise<boolean>;
/** Non-admin: ask the coordinator to be added (publishes a key package). */ /** Non-admin: ask the coordinator to be added (publishes a key package). */
@ -110,6 +110,8 @@ export interface UseCordnNativeResult {
} }
const POLL_MS = 6000; 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 * 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]); }, [enabled, pubkey, signer, coordinator, groupId, reloadKey]);
const refresh = useCallback(async () => { const refresh = useCallback(async (): Promise<boolean> => {
const session = sessionRef.current; const session = sessionRef.current;
if (!session || !group) return; if (!session || !group) return false;
try { try {
await session.fetch(group.gid); await session.fetch(group.gid);
const next = session.messages(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); setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group);
setError(null); setError(null);
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
return true;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
return false;
} }
}, [group]); }, [group]);
// Live polling for silent signers only — an interactive signer would pop for // 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 // 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(() => { useEffect(() => {
if (!ready || !group || interactive || !canBackgroundPoll) return; if (!ready || !group || interactive || !canBackgroundPoll) return;
const id = setInterval(() => void refresh(), POLL_MS); let cancelled = false;
return () => clearInterval(id); let timer: ReturnType<typeof setTimeout> | undefined;
}, [ready, group, interactive, canBackgroundPoll, refresh]); 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 // 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 // is a foreground approval; a double-tap on "Set up" used to start two