Files
offscale/web/ServersView.tsx
pastilhas 58fbf12e4b the picker's register link opens the form
reported as "I click register a server and nothing happens", and nothing was broken. three controls on
that screen carry the label. two are buttons that open the form; the one in the server picker — top left,
where you look first — was a plain <Link> to the servers section.

/offscale and /offscale/servers render the same view (useOffscaleSection defaults to servers when there is
no param), so from either one the click navigated to something identical. the URL moved and nothing else
did, and the only visible result was a second button with the same label further down the page.

so the link now carries ?register=1 and ServersView consumes it. the flag is stripped with replace on the
way in, which is not tidiness: left in the URL, closing the form and clicking the picker again would be a
navigation to the URL already showing, and nothing would happen — the same bug, restored.

ServersView.test.tsx pins all three: the form opens on arrival, the flag is consumed, and no flag means no
form. removing the effect fails the first two.
2026-08-17 12:58:47 +00:00

282 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
import type { OffscaleServer, OffscaleHealth } from './shared';
import { MIN_OFFSCALE_VERSION, OFFSCALE_REGISTER_PARAM } from './shared';
import { useOffscaleServers, useOffscaleHealth, offscaleErrorMessage } from './useOffscaleServers';
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: OffscaleServer;
health: OffscaleHealth | 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 (
<Card>
<div className="flex flex-col gap-3 p-3.5">
<div className="flex items-start gap-2.5">
<span className="mt-1.5">
<Dot tone={tone} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{server.name}</span>
{server.isActive && <Badge tone="active">Active</Badge>}
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500" title={server.url}>
{server.url}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-600">
<span>{server.version ? `Headscale ${server.version}` : 'version unknown'}</span>
{server.lastSeenAt && <span>· reached {timeAgo(server.lastSeenAt)}</span>}
{health?.ok && <span className="text-emerald-400/80">· responded in {health.ms}ms</span>}
</div>
</div>
</div>
{health && !health.ok && <ErrorNote>{health.error ?? 'The server did not respond'}</ErrorNote>}
{health?.ok && health.supported === 'unknown' && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
This server reports its version as “{health.version}”, so Officer cannot confirm it is{' '}
{MIN_OFFSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
builds.
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{!server.isActive && (
<Button variant="primary" onClick={onActivate} disabled={busy}>
<Check className="h-3.5 w-3.5" />
Use this server
</Button>
)}
<Button onClick={onTest} disabled={testing}>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Activity className="h-3.5 w-3.5" />}
{testing ? 'Testing…' : 'Test'}
</Button>
<Button onClick={onEdit} disabled={busy}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={onRemove} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
</Card>
);
};
const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<Server className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
<p className="mt-1 text-sm text-zinc-500">
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_OFFSCALE_VERSION} and newer.
</p>
</div>
<Button variant="primary" onClick={onRegister}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
</div>
);
export const ServersView = () => {
const { servers, isLoading, error, refetch, activate, remove } = useOffscaleServers();
const healthProbe = useOffscaleHealth();
const [formFor, setFormFor] = useState<'new' | OffscaleServer | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [health, setHealth] = useState<Record<number, OffscaleHealth>>({});
// 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);
/*
The URL may ask for the form: the server picker links here with `?register=1` so its "Register a server"
opens the thing rather than landing you on a page that offers the same thing again.
The flag is CONSUMED — read once, then stripped with `replace`. Leaving it in place would break the
control a second time: close the form, click the picker again, and the URL would already say
`?register=1`, so there would be no navigation and nothing would happen. Stripping it also means a
reload does not reopen a form you dismissed.
*/
useEffect(() => {
if (searchParams.get(OFFSCALE_REGISTER_PARAM) !== '1') return;
setFormFor('new');
const next = new URLSearchParams(searchParams);
next.delete(OFFSCALE_REGISTER_PARAM);
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
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: offscaleErrorMessage(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<number>());
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<unknown>) => {
setActionError(null);
try {
await fn();
} catch (err) {
setActionError(offscaleErrorMessage(err));
}
};
const busy = activate.isPending || remove.isPending;
return (
<div className="h-full overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<SectionHeader
title="Servers"
subtitle="One server is active at a time; every other section acts on it."
action={
!formFor && (
<Button variant="primary" onClick={() => setFormFor('new')} disabled={isLoading}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
)
}
/>
{/* 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 && (
<ErrorNote>
<div className="flex flex-wrap items-center gap-2">
<span>
Could not reach the Headscale sidecar: {offscaleErrorMessage(error)}. If it is not running, start it
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-offscale</code>.
</span>
<button
type="button"
onClick={() => void refetch()}
className="ml-auto shrink-0 cursor-pointer rounded-lg border border-red-500/30 px-2 py-1 font-medium transition-colors hover:bg-red-500/20"
>
Try again
</button>
</div>
</ErrorNote>
)}
{/* 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 && (
<ServerForm
key={formFor === 'new' ? 'new' : formFor.id}
server={formFor === 'new' ? null : formFor}
onClose={() => setFormFor(null)}
/>
)}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{isLoading && (
<div className="flex items-center justify-center py-16 text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers…
</div>
)}
{/* 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 && (
<EmptyState onRegister={() => setFormFor('new')} />
)}
{servers.map((server) => (
<ServerRow
key={server.id}
server={server}
health={health[server.id]}
testing={testingIds.includes(server.id)}
busy={busy}
onActivate={() => void run(() => activate.mutateAsync(server.id))}
onTest={() => void test(server.id)}
onEdit={() => setFormFor(server)}
onRemove={() => void run(() => remove.mutateAsync(server.id))}
/>
))}
</div>
</div>
);
};