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 mutation = editing ? update : register;
const pending = mutation.isPending; 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 // 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 // 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. // this field is supposed to survive.
@@ -60,7 +69,10 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
}; };
const submit = async () => { const submit = async () => {
if (pending) return;
setError(null); 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 (!url.trim()) return setError('A server URL is required');
if (!editing && !apiKey.trim()) return setError('An API key is required'); if (!editing && !apiKey.trim()) return setError('An API key is required');
@@ -105,7 +117,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
<Field <Field
label="Server URL" label="Server URL"
value={url} value={url}
onChange={setUrl} onChange={edit(setUrl)}
placeholder="https://headscale.example.com" placeholder="https://headscale.example.com"
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`} hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
autoFocus={!editing} autoFocus={!editing}
@@ -113,7 +125,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
<Field <Field
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'} label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
value={apiKey} value={apiKey}
onChange={setApiKey} onChange={edit(setApiKey)}
type="password" type="password"
placeholder="hskey-api-..." placeholder="hskey-api-..."
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer." 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 <Field
label="Name (optional)" label="Name (optional)"
value={name} value={name}
onChange={setName} onChange={edit(setName)}
placeholder="defaults to the hostname" placeholder="defaults to the hostname"
hint="A label for switching between servers." hint="A label for switching between servers."
/> />
@@ -139,10 +151,10 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
<Field <Field
label="SSH address" label="SSH address"
value={sshHost} value={sshHost}
onChange={(value) => { onChange={edit((value: string) => {
setSshHost(value); setSshHost(value);
setSshResult(null); setSshResult(null);
}} })}
placeholder="203.0.113.10 or root@203.0.113.10" 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." 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 = () => { export const ServersView = () => {
const { servers, isLoading, error, activate, remove } = useHeadscaleServers(); const { servers, isLoading, error, refetch, activate, remove } = useHeadscaleServers();
const healthProbe = useHeadscaleHealth(); const healthProbe = useHeadscaleHealth();
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null); const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
@@ -160,54 +179,6 @@ export const ServersView = () => {
const busy = activate.isPending || remove.isPending; 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 ( return (
<div className="h-full overflow-y-auto p-4"> <div className="h-full overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3"> <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." subtitle="One server is active at a time; every other section acts on it."
action={ action={
!formFor && ( !formFor && (
<Button variant="primary" onClick={() => setFormFor('new')}> <Button variant="primary" onClick={() => setFormFor('new')} disabled={isLoading}>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Register a server Register a server
</Button> </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 {/* 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 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. */} 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>} {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) => ( {servers.map((server) => (
<ServerRow <ServerRow
key={server.id} key={server.id}