From 55abaa4042f2b5d08a2b272135292e66f02578ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 5 Aug 2026 17:07:18 +0000 Subject: [PATCH] headscale: probe every server when the list opens --- .../src/apps/Headscale/ServersView.tsx | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx b/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx index 50c9e99e..a15965f5 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx @@ -9,12 +9,11 @@ import { ServerForm } from './ServerForm'; // The servers section — register Headscale servers and switch between them. Exactly one is active at a // time (a DB invariant, not a UI convention), and every other section in this workspace reads it. // -// The ACTIVE server is probed once when this section opens; the others only when asked. A probe costs two -// upstream round trips (an unauthenticated /version plus an authenticated call to prove the key still -// works), which is worth spending on the one server everything else acts on, and rude to spend on servers -// the owner isn't currently using. The point of probing the active one automatically is that its dot then -// always states something — green or red — instead of an amber "haven't checked" that the working screen -// behind it already contradicts. +// EVERY server is probed when this section opens, in parallel, and again for any server registered while +// it is open. A probe costs two upstream round trips (an unauthenticated /version plus an authenticated +// call to prove the stored key still works) — cheap enough at this scale, and the alternative was worse: +// a grey "not checked" dot is the one thing this list must never show, because the reason to look at it +// is to find out which servers are up. A dot that says nothing makes the whole page say nothing. function timeAgo(iso: string): string { const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000); @@ -41,9 +40,8 @@ type ServerRowProps = { const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, onRemove }: ServerRowProps) => { const [confirming, setConfirming] = useState(false); - // Amber is only ever the in-flight state for the active server's own probe — an unprobed server is grey, - // because a green dot should mean "I checked", not "nothing has gone wrong that I noticed". - const tone = health ? (health.ok ? 'ok' : 'bad') : server.isActive ? 'warn' : 'idle'; + // Amber means "asking"; grey should only ever be the frame before the automatic probe starts. + const tone = health ? (health.ok ? 'ok' : 'bad') : testing ? 'warn' : 'idle'; return ( @@ -120,31 +118,36 @@ export const ServersView = () => { const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null); const [health, setHealth] = useState>({}); - const [testingId, setTestingId] = useState(null); + // Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be. + const [testingIds, setTestingIds] = useState([]); const [actionError, setActionError] = useState(null); const test = async (id: number) => { - setTestingId(id); + setTestingIds((prev) => (prev.includes(id) ? prev : [...prev, id])); setActionError(null); try { const result = await healthProbe.mutateAsync(id); setHealth((prev) => ({ ...prev, [id]: result })); } catch (err) { + // A probe that throws is still an answer about the server: record it as a red dot rather than as a + // page-level error, which would blame the whole screen for one unreachable box. setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } })); } finally { - setTestingId(null); + setTestingIds((prev) => prev.filter((t) => t !== id)); } }; - // Probe the active server once per activation. Keyed on the id rather than a boolean so switching servers - // probes the new one, and re-rendering doesn't re-probe the same one. - const activeId = servers.find((s) => s.isActive)?.id ?? null; - const autoProbed = useRef(null); + // Probe every server once per visit to this section, and any server that appears while it is open. The + // ref is what makes "once" true: the list identity changes when a probe writes lastSeenAt, and without + // it each result would trigger the next round forever. + const probed = useRef(new Set()); useEffect(() => { - if (activeId === null || autoProbed.current === activeId) return; - autoProbed.current = activeId; - void test(activeId); - }, [activeId]); + for (const server of servers) { + if (probed.current.has(server.id)) continue; + probed.current.add(server.id); + void test(server.id); + } + }, [servers]); const run = async (fn: () => Promise) => { setActionError(null); @@ -239,7 +242,7 @@ export const ServersView = () => { key={server.id} server={server} health={health[server.id]} - testing={testingId === server.id} + testing={testingIds.includes(server.id)} busy={busy} onActivate={() => void run(() => activate.mutateAsync(server.id))} onTest={() => void test(server.id)}