keep the headscale server form on screen when the list query fails

This commit is contained in:
2026-08-06 03:09:38 +00:00
parent 217d5ed29a
commit b6b9b01aa6
2 changed files with 73 additions and 55 deletions
@@ -45,6 +45,15 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
const mutation = editing ? update : register;
const pending = mutation.isPending;
// A rejection leaves its reason under the button, and the reason is about values that have since been
// corrected. Editing anything clears it, so a stale message can never make a live form look dead.
const edit =
<T,>(set: (value: T) => void) =>
(value: T) => {
set(value);
setError(null);
};
// The point of a separate SSH address is reaching the box when the tailnet or Headscale itself is down. If
// it resolves through the same name the control server does, it goes down with it — which is the one thing
// this field is supposed to survive.
@@ -60,7 +69,10 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
};
const submit = async () => {
if (pending) return;
setError(null);
// Drop the previous rejection from the mutation too — this is a fresh attempt, not a retry of that one.
mutation.reset();
if (!url.trim()) return setError('A server URL is required');
if (!editing && !apiKey.trim()) return setError('An API key is required');
@@ -105,7 +117,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
<Field
label="Server URL"
value={url}
onChange={setUrl}
onChange={edit(setUrl)}
placeholder="https://headscale.example.com"
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
autoFocus={!editing}
@@ -113,7 +125,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
<Field
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
value={apiKey}
onChange={setApiKey}
onChange={edit(setApiKey)}
type="password"
placeholder="hskey-api-..."
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
@@ -121,7 +133,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
<Field
label="Name (optional)"
value={name}
onChange={setName}
onChange={edit(setName)}
placeholder="defaults to the hostname"
hint="A label for switching between servers."
/>
@@ -139,10 +151,10 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
<Field
label="SSH address"
value={sshHost}
onChange={(value) => {
onChange={edit((value: string) => {
setSshHost(value);
setSshResult(null);
}}
})}
placeholder="203.0.113.10 or root@203.0.113.10"
hint="Use the machine's own address, not the Headscale hostname. Leave blank for no console."
/>
@@ -112,8 +112,27 @@ const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit,
);
};
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_HEADSCALE_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, activate, remove } = useHeadscaleServers();
const { servers, isLoading, error, refetch, activate, remove } = useHeadscaleServers();
const healthProbe = useHeadscaleHealth();
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
@@ -160,54 +179,6 @@ export const ServersView = () => {
const busy = activate.isPending || remove.isPending;
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
);
}
if (error) {
return (
<div className="p-4">
<ErrorNote>
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it with{' '}
<code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</code>.
</ErrorNote>
</div>
);
}
// Empty state doubles as the registration prompt — there is nothing else to do here without a server.
if (servers.length === 0) {
return (
<div className="mx-auto flex h-full w-full max-w-xl flex-col justify-center gap-4 p-6">
{formFor ? (
<ServerForm onClose={() => setFormFor(null)} />
) : (
<div className="flex flex-col items-center gap-4 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_HEADSCALE_VERSION} and newer.
</p>
</div>
<Button variant="primary" onClick={() => setFormFor('new')}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
</div>
)}
</div>
);
}
return (
<div className="h-full overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
@@ -216,7 +187,7 @@ export const ServersView = () => {
subtitle="One server is active at a time; every other section acts on it."
action={
!formFor && (
<Button variant="primary" onClick={() => setFormFor('new')}>
<Button variant="primary" onClick={() => setFormFor('new')} disabled={isLoading}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
@@ -224,6 +195,28 @@ export const ServersView = () => {
}
/>
{/* 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: {headscaleErrorMessage(error)}. If it is not running, start it
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</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. */}
@@ -237,6 +230,19 @@ export const ServersView = () => {
{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}