keep the headscale server form on screen when the list query fails
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user