headscale: probe every server when the list opens
This commit is contained in:
@@ -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 (
|
||||
<Card>
|
||||
@@ -120,31 +118,36 @@ export const ServersView = () => {
|
||||
|
||||
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
|
||||
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
|
||||
const [testingId, setTestingId] = useState<number | null>(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<readonly number[]>([]);
|
||||
const [actionError, setActionError] = useState<string | null>(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<number | null>(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<number>());
|
||||
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<unknown>) => {
|
||||
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)}
|
||||
|
||||
Reference in New Issue
Block a user