From b6b9b01aa67699e5f7e28c1fc4133f9845d628db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 6 Aug 2026 03:09:38 +0000 Subject: [PATCH] keep the headscale server form on screen when the list query fails --- .../src/apps/Headscale/ServerForm.tsx | 22 +++- .../src/apps/Headscale/ServersView.tsx | 106 +++++++++--------- 2 files changed, 73 insertions(+), 55 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx b/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx index 18b2e910..3e573416 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx @@ -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 = + (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) => { { { @@ -139,10 +151,10 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => { { + 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." /> diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx b/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx index a15965f5..4c05218f 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx @@ -112,8 +112,27 @@ const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, ); }; +const EmptyState = ({ onRegister }: { onRegister: () => void }) => ( +
+
+ +
+
+
No Headscale servers yet
+

+ 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, 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 ( -
- - Loading servers… -
- ); - } - - if (error) { - return ( -
- - Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it with{' '} - pm2 start ecosystem.config.cjs --only officer-headscale. - -
- ); - } - - // Empty state doubles as the registration prompt — there is nothing else to do here without a server. - if (servers.length === 0) { - return ( -
- {formFor ? ( - setFormFor(null)} /> - ) : ( -
-
- -
-
-
No Headscale servers yet
-

- 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. -

-
- -
- )} -
- ); - } - return (
@@ -216,7 +187,7 @@ export const ServersView = () => { subtitle="One server is active at a time; every other section acts on it." action={ !formFor && ( - @@ -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 && ( + +
+ + 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. */} @@ -237,6 +230,19 @@ export const ServersView = () => { {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) => (