import { useEffect, useRef, useState } from 'react'; import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react'; import type { HeadscaleServer, HeadscaleHealth } from './shared'; import { MIN_HEADSCALE_VERSION } from './shared'; import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers'; import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards'; 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. // // 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); if (!Number.isFinite(seconds)) return 'unknown'; if (seconds < 60) return 'just now'; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.round(hours / 24)}d ago`; } type ServerRowProps = { server: HeadscaleServer; health: HeadscaleHealth | undefined; testing: boolean; busy: boolean; onActivate: () => void; onTest: () => void; onEdit: () => void; onRemove: () => void; }; const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, onRemove }: ServerRowProps) => { const [confirming, setConfirming] = useState(false); // 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 (
{server.name} {server.isActive && Active}
{server.url}
{server.version ? `Headscale ${server.version}` : 'version unknown'} {server.lastSeenAt && · reached {timeAgo(server.lastSeenAt)}} {health?.ok && · responded in {health.ms}ms}
{health && !health.ok && {health.error ?? 'The server did not respond'}} {health?.ok && health.supported === 'unknown' && (
This server reports its version as “{health.version}”, so Officer cannot confirm it is{' '} {MIN_HEADSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older builds.
)}
{!server.isActive && ( )} {confirming ? ( <> ) : ( )}
); }; const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
No Headscale servers yet

Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here. Officer supports Headscale {MIN_HEADSCALE_VERSION} and newer.

); export const ServersView = () => { const { servers, isLoading, error, refetch, activate, remove } = useHeadscaleServers(); const healthProbe = useHeadscaleHealth(); const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null); const [health, setHealth] = useState>({}); // 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) => { 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 { setTestingIds((prev) => prev.filter((t) => t !== id)); } }; // 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(() => { 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); try { await fn(); } catch (err) { setActionError(headscaleErrorMessage(err)); } }; const busy = activate.isPending || remove.isPending; return (
setFormFor('new')} disabled={isLoading}> Register a server ) } /> {/* A failed list fetch is reported here, NOT as a replacement for the whole section. It used to be an early return, which unmounted the form mid-registration and threw away everything typed into it — leaving a reload as the only way to try again. Nothing on this screen may take the form off the page except the owner. */} {error && (
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it with pm2 start ecosystem.config.cjs --only officer-headscale.
)} {/* Keyed by which server it edits: the form seeds its fields from the prop once, at mount, so switching straight from one server's Edit to another's would otherwise keep the first one's values — and submit diffs those stale values against the NEW server, writing them to it. */} {formFor && ( setFormFor(null)} /> )} {actionError && {actionError}} {isLoading && (
Loading servers…
)} {/* Empty state doubles as the registration prompt — there is nothing else to do here without a server. Hidden while the form is open, because it is then the same offer twice. */} {!isLoading && !error && servers.length === 0 && !formFor && ( setFormFor('new')} /> )} {servers.map((server) => ( void run(() => activate.mutateAsync(server.id))} onTest={() => void test(server.id)} onEdit={() => setFormFor(server)} onRemove={() => void run(() => remove.mutateAsync(server.id))} /> ))}
); };