add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and their admin api keys, the >=0.29 version floor, and every multi-call composition the ui needs. the platform side is auth+forward only and holds no headscale credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars are untouched and unrelated. officer manages many servers rather than one. the owner registers each with a url and a key generated on that server and switches between them; exactly one is active, enforced by a partial unique index rather than by convention. keys are encrypted at rest and never leave the sidecar — the list projection cannot return one. registration validates before it saves: an unauthenticated GET /version to prove something headscale-shaped is there and meets the floor, then an authenticated call to prove the key works. an edit that moves either half re-validates. there is deliberately no transparent /api/v1/* passthrough. headscale serialises every uint64 as a json string and its rest shape moved repeatedly below 0.29; proxying raw would push all of that into the browser, which is the mistake the soulseek panels made with 37 raw upstream calls. the /headscale workspace is nav + view over the panel system. only the servers section is implemented — nodes, users and pre-auth keys say so plainly rather than rendering an empty table that reads as a failed fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { useAppRegistry } from './useAppRegistry';
|
||||
import { useUserApps } from 'state/useUserApps';
|
||||
@@ -18,7 +19,22 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||
import { resolveIcon } from '../utils/resolve-icon';
|
||||
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...soulseekMetas, ...monitorMetas];
|
||||
const apps = [
|
||||
...fileBrowserMetas,
|
||||
...terminalMetas,
|
||||
...codeEditorMetas,
|
||||
...chatMetas,
|
||||
...fileViewerMetas,
|
||||
...dashboardMetas,
|
||||
...chatHistoryMetas,
|
||||
...previewMetas,
|
||||
...widgetMetas,
|
||||
...desktopMetas,
|
||||
...musicMetas,
|
||||
...soulseekMetas,
|
||||
...headscaleMetas,
|
||||
...monitorMetas,
|
||||
];
|
||||
|
||||
export const AppRegistry = () => {
|
||||
const { registerApp } = useAppRegistry(apps);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black
|
||||
// cards on hairline white borders. Kept local to the app so the look changes in one place.
|
||||
|
||||
export const Card = ({ children }: { children: ReactNode }) => (
|
||||
<div className="overflow-hidden rounded-xl border border-white/10 bg-zinc-950 shadow-sm">{children}</div>
|
||||
);
|
||||
|
||||
export const SectionHeader = ({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}) => (
|
||||
<div className="flex items-start justify-between gap-4 px-1 pb-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-zinc-500">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
type ButtonProps = {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit';
|
||||
variant?: 'primary' | 'ghost' | 'danger';
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<NonNullable<ButtonProps['variant']>, string> = {
|
||||
primary: 'border-primary/40 bg-primary/15 text-primary hover:bg-primary/25',
|
||||
ghost: 'border-white/10 bg-white/[0.02] text-zinc-300 hover:bg-white/10 hover:text-zinc-100',
|
||||
danger: 'border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20',
|
||||
};
|
||||
|
||||
export const Button = ({ children, onClick, type = 'button', variant = 'ghost', disabled, title }: ButtonProps) => (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-40 ${VARIANTS[variant]}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
type?: 'text' | 'password';
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export const Field = ({ label, value, onChange, placeholder, hint, type = 'text', autoFocus }: FieldProps) => (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-zinc-400">{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50"
|
||||
/>
|
||||
{hint && <span className="text-[11px] leading-snug text-zinc-600">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
/** Tiny status light: green healthy, amber unknown/unverified, red failing. */
|
||||
export const Dot = ({ tone }: { tone: 'ok' | 'warn' | 'bad' | 'idle' }) => {
|
||||
const color =
|
||||
tone === 'ok' ? 'bg-emerald-400' : tone === 'warn' ? 'bg-amber-400' : tone === 'bad' ? 'bg-red-400' : 'bg-zinc-600';
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} />;
|
||||
};
|
||||
|
||||
export const Badge = ({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'active' }) => (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${
|
||||
tone === 'active' ? 'border-primary/40 bg-primary/10 text-primary' : 'border-white/10 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const ErrorNote = ({ children }: { children: ReactNode }) => (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs leading-snug text-red-300">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
|
||||
// Left panel of the /headscale workspace: the active-server switcher on top, sections below. Publishes the
|
||||
// selected section on 'headscale:section'; HeadscaleView (right) renders the matching UI.
|
||||
//
|
||||
// Switching servers is the primary action here rather than a buried setting — the owner runs several
|
||||
// control servers and every other section is scoped to whichever is active.
|
||||
|
||||
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
|
||||
servers: Server,
|
||||
nodes: Laptop,
|
||||
users: Users,
|
||||
keys: KeyRound,
|
||||
};
|
||||
|
||||
export const HeadscaleNav = () => {
|
||||
const [section, setSection] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
|
||||
const { servers, active, activate } = useHeadscaleServers();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/15 text-indigo-400 ring-1 ring-black/5">
|
||||
<Network className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{servers.length > 1 && (
|
||||
<div className="px-2 pb-3">
|
||||
<div className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Server
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{servers.map((server) => {
|
||||
const isActive = server.isActive;
|
||||
return (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
onClick={() => !isActive && activate.mutate(server.id)}
|
||||
disabled={activate.isPending}
|
||||
title={server.url}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${isActive ? 'text-primary' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{server.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{HEADSCALE_SECTIONS.map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
const selected = section === id;
|
||||
// Without an active server there is nothing for the domain sections to act on.
|
||||
const disabled = id !== 'servers' && !active;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setSection(id)}
|
||||
disabled={disabled}
|
||||
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
} ${disabled ? 'cursor-default opacity-40 hover:bg-transparent hover:text-muted-foreground' : ''}`}
|
||||
>
|
||||
{selected && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${selected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
|
||||
import { ServersView } from './ServersView';
|
||||
|
||||
// Right panel of the /headscale workspace — renders the section the nav selected.
|
||||
//
|
||||
// Only `servers` is implemented. Nodes, users and pre-auth keys need the sidecar's domain routes, which
|
||||
// don't exist yet; they say so plainly rather than rendering an empty table that looks like a broken fetch.
|
||||
|
||||
const Placeholder = ({ id }: { id: HeadscaleSectionId }) => {
|
||||
const label = HEADSCALE_SECTIONS.find((s) => s.id === id)?.label ?? id;
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Construction className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">Not built yet</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HeadscaleView = () => {
|
||||
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
|
||||
|
||||
if (section === 'servers') return <ServersView />;
|
||||
return <Placeholder id={section} />;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Network } from 'lucide-react';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
|
||||
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
|
||||
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
|
||||
|
||||
export const HeadscaleViewHeader = () => {
|
||||
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
|
||||
const { active } = useHeadscaleServers();
|
||||
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Network className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">
|
||||
{label}
|
||||
{active && <span className="ml-1.5 font-normal text-black/50">· {active.name}</span>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { HeadscaleServer } from './shared';
|
||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
||||
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { Card, Button, Field, ErrorNote } from './Cards';
|
||||
|
||||
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
|
||||
// key actually accepted — so this form is genuinely slow on submit and genuinely fails. Both are shown:
|
||||
// a pending state saying what is being checked, and the server's own reason inline on rejection.
|
||||
//
|
||||
// On edit the API key field is intentionally blank rather than pre-filled. Officer cannot pre-fill it (the
|
||||
// key is encrypted at rest and never leaves the sidecar), and leaving it empty means "keep the current key".
|
||||
|
||||
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
|
||||
|
||||
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
const { register, update } = useHeadscaleServers();
|
||||
const editing = !!server;
|
||||
|
||||
const [name, setName] = useState(server?.name ?? '');
|
||||
const [url, setUrl] = useState(server?.url ?? '');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = editing ? update : register;
|
||||
const pending = mutation.isPending;
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (!url.trim()) return setError('A server URL is required');
|
||||
if (!editing && !apiKey.trim()) return setError('An API key is required');
|
||||
|
||||
try {
|
||||
if (editing && server) {
|
||||
// Send only what changed: an unchanged url+key pair skips the sidecar's re-validation round trips.
|
||||
await update.mutateAsync({
|
||||
id: server.id,
|
||||
name: name.trim() || undefined,
|
||||
url: url.trim() === server.url ? undefined : url.trim(),
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
await register.mutateAsync({ name: name.trim() || undefined, url: url.trim(), apiKey: apiKey.trim() });
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="flex flex-col gap-3 p-4"
|
||||
>
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Server URL"
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
placeholder="https://headscale.example.com"
|
||||
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
|
||||
autoFocus={!editing}
|
||||
/>
|
||||
<Field
|
||||
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
type="password"
|
||||
placeholder="hskey-api-..."
|
||||
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
|
||||
/>
|
||||
<Field
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="defaults to the hostname"
|
||||
hint="A label for switching between servers."
|
||||
/>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{pending ? 'Verifying…' : editing ? 'Save changes' : 'Register server'}
|
||||
</Button>
|
||||
<Button onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
{pending && <span className="text-[11px] text-zinc-500">Checking the server and the key…</span>}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { HeadscaleServer, HeadscaleHealth } from './shared';
|
||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
||||
import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
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.
|
||||
//
|
||||
// Health is probed on demand only. It costs two upstream round trips (an unauthenticated /version plus an
|
||||
// authenticated call to prove the key still works), so polling every registered server would be rude to
|
||||
// servers the owner isn't currently using.
|
||||
|
||||
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: HeadscaleServer;
|
||||
health: HeadscaleHealth | 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);
|
||||
|
||||
// Untested servers get a neutral dot, not a green one: we only know the credentials worked at registration.
|
||||
const tone = health ? (health.ok ? 'ok' : 'bad') : server.isActive ? '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_HEADSCALE_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>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServersView = () => {
|
||||
const { servers, isLoading, error, activate, remove } = useHeadscaleServers();
|
||||
const healthProbe = useHeadscaleHealth();
|
||||
|
||||
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
|
||||
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const test = async (id: number) => {
|
||||
setTestingId(id);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await healthProbe.mutateAsync(id);
|
||||
setHealth((prev) => ({ ...prev, [id]: result }));
|
||||
} catch (err) {
|
||||
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setActionError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<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')}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Register a server
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{formFor && <ServerForm server={formFor === 'new' ? null : formFor} onClose={() => setFormFor(null)} />}
|
||||
|
||||
{actionError && <ErrorNote>{actionError}</ErrorNote>}
|
||||
|
||||
{servers.map((server) => (
|
||||
<ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
health={health[server.id]}
|
||||
testing={testingId === 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { HeadscaleNav } from './HeadscaleNav';
|
||||
import { HeadscaleView } from './HeadscaleView';
|
||||
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
|
||||
|
||||
export { HeadscaleNav, HeadscaleView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
|
||||
{
|
||||
key: 'headscale-view',
|
||||
name: 'Headscale',
|
||||
icon: LayoutGrid,
|
||||
component: HeadscaleView,
|
||||
header: HeadscaleViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
|
||||
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
|
||||
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
|
||||
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
|
||||
// version. See src/servers/sidecar/headscale/routes.ts.
|
||||
|
||||
/** Selected section, published by HeadscaleNav and consumed by HeadscaleView. */
|
||||
export const HEADSCALE_SECTION_CHANNEL = 'headscale:section';
|
||||
|
||||
export const HEADSCALE_SECTIONS = [
|
||||
{ id: 'servers', label: 'Servers' },
|
||||
{ id: 'nodes', label: 'Nodes' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'keys', label: 'Pre-auth keys' },
|
||||
] as const;
|
||||
|
||||
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
|
||||
|
||||
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
|
||||
export type HeadscaleServer = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
version: string | null;
|
||||
isActive: boolean;
|
||||
/** ISO string, or null when we have never successfully probed it. */
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
|
||||
export type HeadscaleHealth = {
|
||||
ok: boolean;
|
||||
version?: string;
|
||||
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
|
||||
supported?: boolean | 'unknown';
|
||||
error?: string;
|
||||
ms: number;
|
||||
};
|
||||
|
||||
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
|
||||
export const MIN_HEADSCALE_VERSION = '0.29';
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { HeadscaleServer, HeadscaleHealth } from './shared';
|
||||
|
||||
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
|
||||
// the active server anywhere updates the whole screen at once.
|
||||
//
|
||||
// Registration is validated server-side before anything is saved (reachable, >=0.29, key accepted), which
|
||||
// means a POST can fail for perfectly ordinary reasons — a typo'd URL, a revoked key. Those are not
|
||||
// exceptional here, so the mutations surface their message rather than swallowing it.
|
||||
|
||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
||||
const EMPTY: HeadscaleServer[] = [];
|
||||
|
||||
const BASE = '/headscale/_officer/servers';
|
||||
|
||||
/**
|
||||
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
||||
* body text — JSON `{error}` from our sidecar, but plain text from the platform's own 401/503 paths.
|
||||
*/
|
||||
export function headscaleErrorMessage(err: unknown): string {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
return raw.slice(0, 300);
|
||||
}
|
||||
|
||||
export type RegisterServerInput = { name?: string; url: string; apiKey: string };
|
||||
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string };
|
||||
|
||||
export function useHeadscaleServers() {
|
||||
const { get, post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: SERVERS_KEY,
|
||||
queryFn: () => get<{ servers: HeadscaleServer[] }>(BASE),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: SERVERS_KEY });
|
||||
|
||||
const register = useMutation({
|
||||
mutationFn: (input: RegisterServerInput) => post<{ server: HeadscaleServer }>(BASE, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: HeadscaleServer }>(`${BASE}/${id}`, rest),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => del(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: number) => post<{ server: HeadscaleServer }>(`${BASE}/${id}/activate`),
|
||||
// Deleting or switching reshuffles which server is active, and every domain query is scoped to it.
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['headscale'] }),
|
||||
});
|
||||
|
||||
const servers = query.data?.servers ?? EMPTY;
|
||||
|
||||
return {
|
||||
servers,
|
||||
active: servers.find((s) => s.isActive) ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
register,
|
||||
update,
|
||||
remove,
|
||||
activate,
|
||||
};
|
||||
}
|
||||
|
||||
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
|
||||
export function useHeadscaleHealth() {
|
||||
const { get } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => get<HeadscaleHealth>(`${BASE}/${id}/health`),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user