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=$(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=""
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,13 +250,31 @@ 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
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void copyNprofile()}
|
type="button"
|
||||||
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
|
onClick={() => void copyNprofile()}
|
||||||
>
|
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
|
||||||
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
|
>
|
||||||
</button>
|
<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. */
|
/** 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue