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 (
{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.
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))}
/>
))}