offscale is a plugin

headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
+103
View File
@@ -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>
);
+96
View File
@@ -0,0 +1,96 @@
import { useCallback } from 'react';
import { Link } from 'react-router';
import { Loader2, TerminalSquare } from 'lucide-react';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
import { TerminalView } from 'officerdev';
import { Button } from './Cards';
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
// answer (why headscale won't start, what the logs say, whether the disk is full).
//
// It is deliberately the SAME terminal every other panel uses, driven by nothing more than `ssh <host>` typed
// into a login shell. Officer holds no key, no password and no port: whatever `ssh` on this box can already
// reach, this can reach, and nothing more. If the connection needs a jump host or an odd port, that belongs in
// `~/.ssh/config` as a Host alias — which this field accepts by name.
//
// The session id is derived from the server id rather than minted per panel, so re-opening the Console lands
// back in the shell that is already running and mid-command, and switching servers is a different shell rather
// than the same one re-purposed. TerminalView suppresses its initial input when the sidecar replays a buffer,
// which is what stops a re-attach from typing a second `ssh` inside the first.
const consoleSessionId = (serverId: number) => `headscale-console-${serverId}`;
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<TerminalSquare className="h-6 w-6" />
</div>
{children}
</div>
);
export const ConsoleView = () => {
const { active, isLoading } = useHeadscaleServers();
// The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants
// a stable callback and an inline arrow would remount its effect on every render.
const onConnectionChange = useCallback(() => {}, []);
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 (!active) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to open its console.</p>
</div>
</Centred>
);
}
if (!active.sshHost) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No SSH address for {active.name}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">
Add one on the server to open a shell on the machine behind it. Use the machine's own address rather than
the Headscale hostname — the console is most useful exactly when that name has stopped answering.
</p>
</div>
<Link to={headscaleSectionPath('servers')}>
<Button variant="primary">Go to Servers</Button>
</Link>
</Centred>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<TerminalSquare className="h-3.5 w-3.5" />
<span className="truncate">
ssh <span className="font-mono text-zinc-300">{active.sshHost}</span> · {active.name}
</span>
</div>
<TerminalView
// Remount on a server switch: the session id is a mount-time argument, so without this the panel would
// keep showing the previous server's shell under the new server's name.
key={active.id}
className="min-h-0 flex-1 p-2"
sessionId={consoleSessionId(active.id)}
initialInput={`ssh ${active.sshHost}`}
onConnectionChange={onConnectionChange}
/>
</div>
);
};
+326
View File
@@ -0,0 +1,326 @@
import { useEffect, useRef, useState } from 'react';
import {
Activity,
AlertTriangle,
Loader2,
Play,
PlugZap,
RotateCw,
ScrollText,
Square,
Trash2,
Unplug,
} from 'lucide-react';
import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared';
import { timeAgo } from './format';
import { useHeadscaleServers } from './useHeadscaleServers';
import {
useCompanionAction,
useCompanionHealth,
useCompanionLogStream,
useCompanionLogs,
} from './useHeadscaleCompanion';
import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// What the admin API structurally cannot tell you: is the container running, what did it log on the way
// down, and can we bring it back. All of it comes from the Officer Companion deployed alongside the server.
//
// The companion is optional and per-server, so "not deployed" is the ordinary case for a server that has
// never had one, and is rendered as an explanation rather than an error. Note the two inversions this
// section is built around: the companion's /health is ALWAYS HTTP 200 (read `verdict`, never the status),
// and an unavailable companion says nothing about Headscale itself — the admin API on the same domain is
// independent and the other sections may be working perfectly.
const VERDICTS: Record<CompanionVerdict, { tone: 'ok' | 'warn' | 'bad' | 'idle'; label: string; blurb: string }> = {
ok: { tone: 'ok', label: 'Healthy', blurb: 'The container is running and Headscale is answering.' },
degraded: {
tone: 'warn',
label: 'Degraded',
blurb: 'The container is running, but Headscale is not answering properly.',
},
down: { tone: 'bad', label: 'Down', blurb: 'The container is not running.' },
unknown: { tone: 'idle', label: 'Unknown', blurb: 'Docker does not know this container.' },
};
const ACTIONS: { id: CompanionAction; label: string; icon: typeof RotateCw; confirm: string }[] = [
{
id: 'restart',
label: 'Restart',
icon: RotateCw,
confirm: 'Restart the Headscale container? Every node loses its control-plane connection until it is back.',
},
{
id: 'stop',
label: 'Stop',
icon: Square,
confirm: 'Stop the Headscale container? Every node stays disconnected until you start it again.',
},
{ id: 'start', label: 'Start', icon: Play, confirm: 'Start the Headscale container?' },
];
/** The RFC3339 zero date the companion passes through from docker for a container that never finished. */
const isZeroDate = (iso: string) => iso.startsWith('0001-');
const Row = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex items-baseline justify-between gap-4 py-1.5 text-xs">
<span className="shrink-0 text-zinc-500">{label}</span>
<span className="min-w-0 truncate text-right text-zinc-300">{value}</span>
</div>
);
const ContainerFacts = ({ container }: { container: CompanionContainer }) => (
<div className="divide-y divide-white/5">
<Row label="Container" value={container.status} />
<Row label="Healthcheck" value={container.healthcheck ?? 'none defined'} />
<Row label="Started" value={timeAgo(container.startedAt)} />
{!container.running && !isZeroDate(container.finishedAt) && (
<Row label="Exited" value={`${timeAgo(container.finishedAt)} · code ${container.exitCode}`} />
)}
<Row label="Restarts" value={container.restartCount === 0 ? 'none' : `${container.restartCount} by docker`} />
</div>
);
/** Evidence, shown only when the verdict is not ok — likely causes first, then the raw tail behind them. */
const Evidence = ({ health }: { health: CompanionHealthBody }) => {
const causes = health.likelyCauses ?? [];
const recent = health.recentLogs ?? [];
if (causes.length === 0 && recent.length === 0 && !health.healthcheckOutput) return null;
return (
<div className="flex flex-col gap-3 border-t border-white/10 p-4">
{causes.length > 0 && (
<div>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-amber-300">
<AlertTriangle className="h-3.5 w-3.5" />
Likely causes
</div>
<ul className="flex flex-col gap-1">
{causes.map((cause) => (
<li key={cause} className="rounded-md bg-amber-500/10 px-2.5 py-1.5 text-xs text-amber-200/90">
{cause}
</li>
))}
</ul>
{/* The companion reads these out of the logs heuristically. Saying so is the difference between a
hint the owner checks and a diagnosis they trust and then chase down the wrong hole. */}
<p className="mt-1 text-[11px] text-zinc-600">Guessed from the logs treat them as leads, not answers.</p>
</div>
)}
{health.healthcheckOutput && (
<div>
<div className="mb-1.5 text-xs font-medium text-zinc-400">Healthcheck output</div>
<pre className="overflow-x-auto rounded-md bg-black/50 p-2.5 font-mono text-[11px] whitespace-pre-wrap text-zinc-400">
{health.healthcheckOutput}
</pre>
</div>
)}
{recent.length > 0 && (
<div>
<div className="mb-1.5 text-xs font-medium text-zinc-400">Last lines before now</div>
<pre className="max-h-48 overflow-auto rounded-md bg-black/50 p-2.5 font-mono text-[11px] text-zinc-400">
{recent.join('\n')}
</pre>
</div>
)}
</div>
);
};
const TAILS = [100, 500, 2000];
const LogViewer = () => {
const [tail, setTail] = useState(200);
const [follow, setFollow] = useState(false);
const snapshot = useCompanionLogs(tail, !follow);
const stream = useCompanionLogStream(follow, tail);
const boxRef = useRef<HTMLPreElement>(null);
const lines = follow ? stream.lines : snapshot.data?.available ? snapshot.data.lines : [];
// Pin to the bottom while following. Only while following: scrolling a snapshot back to the top and having
// it yanked down again would be the viewer fighting the reader.
useEffect(() => {
if (follow && boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
}, [follow, lines.length]);
const unavailable = !follow && snapshot.data && !snapshot.data.available ? snapshot.data.reason : null;
return (
<Card>
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 px-3 py-2">
<div className="mr-auto flex items-center gap-1.5 text-xs font-medium text-zinc-300">
<ScrollText className="h-3.5 w-3.5" />
Logs
{follow && (
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-zinc-500">
<Dot tone={stream.live ? 'ok' : 'idle'} />
{stream.live ? 'live' : 'stopped'}
</span>
)}
</div>
<div className="flex items-center gap-1">
{TAILS.map((n) => (
<Button key={n} variant={tail === n ? 'primary' : 'ghost'} onClick={() => setTail(n)}>
{n}
</Button>
))}
</div>
<Button variant={follow ? 'primary' : 'ghost'} onClick={() => setFollow((on) => !on)}>
{follow ? <Unplug className="h-3.5 w-3.5" /> : <PlugZap className="h-3.5 w-3.5" />}
{follow ? 'Stop' : 'Follow'}
</Button>
{follow ? (
<Button onClick={stream.clear} title="Clear what has been received">
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : (
<Button onClick={() => void snapshot.refetch()} disabled={snapshot.isFetching}>
{snapshot.isFetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
Refresh
</Button>
)}
</div>
{stream.error && (
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-red-300">{stream.error}</div>
)}
{unavailable && <div className="border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">{unavailable}</div>}
<pre
ref={boxRef}
className="max-h-[26rem] min-h-[12rem] overflow-auto bg-black/40 p-3 font-mono text-[11px] leading-relaxed text-zinc-400"
>
{lines.length > 0
? lines.join('\n')
: snapshot.isLoading
? 'Loading…'
: follow
? 'Waiting for output…'
: 'No log lines.'}
</pre>
</Card>
);
};
const Lifecycle = ({ running }: { running: boolean | null }) => {
const action = useCompanionAction();
const [pending, setPending] = useState<CompanionAction | null>(null);
const run = async (id: CompanionAction, confirm: string) => {
if (!window.confirm(confirm)) return;
setPending(id);
try {
await action.mutateAsync(id);
} catch {
/* surfaced from action.error below */
} finally {
setPending(null);
}
};
const result = action.data;
return (
<div className="flex flex-col gap-2 border-t border-white/10 p-4">
<div className="flex flex-wrap items-center gap-2">
{ACTIONS.map(({ id, label, icon: Icon, confirm }) => (
<Button
key={id}
variant={id === 'stop' ? 'danger' : 'ghost'}
disabled={pending !== null || (running !== null && (id === 'start' ? running : !running))}
onClick={() => void run(id, confirm)}
title={id === 'start' && running ? 'Already running' : undefined}
>
{pending === id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Icon className="h-3.5 w-3.5" />}
{label}
</Button>
))}
<span className="text-[11px] text-zinc-600">Acts on the container, not on Officer.</span>
</div>
{action.error != null && <ErrorNote>The action could not be sent.</ErrorNote>}
{result && !result.available && <ErrorNote>{result.reason}</ErrorNote>}
{result && result.available && !result.ok && (
<ErrorNote>{result.error ?? 'Docker refused the action.'}</ErrorNote>
)}
{result && result.available && result.ok && (
<div className="text-[11px] text-emerald-300">
{result.action}: {result.result}
</div>
)}
</div>
);
};
export const DiagnosticsView = () => {
const { active } = useHeadscaleServers();
const query = useCompanionHealth();
const result = query.data;
return (
<ViewShell isLoading={query.isLoading} error={query.error} label="diagnostics">
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<SectionHeader
title="Diagnostics"
subtitle={active ? `The container behind ${active.name}, as seen from the machine it runs on.` : undefined}
action={query.isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin text-zinc-600" /> : undefined}
/>
{result && !result.available ? (
<Card>
<div className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Activity className="h-4 w-4 text-zinc-500" />
No companion on this server
</div>
<p className="text-xs leading-relaxed text-zinc-500">
{result.reason}. The Officer Companion is a small service deployed next to Headscale that can see its
container it is what makes health, logs and restart possible from here.
</p>
<p className="text-xs leading-relaxed text-zinc-600">
This says nothing about Headscale itself: it is served by the same domain but a different process, so
the other sections may be working normally. When the companion is missing, the Console section is the
way in.
</p>
</div>
</Card>
) : result ? (
<>
<Card>
<div className="flex items-start gap-3 p-4">
<div className="mt-1">
<Dot tone={VERDICTS[result.health.verdict].tone} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-100">{VERDICTS[result.health.verdict].label}</div>
<p className="mt-0.5 text-xs text-zinc-500">
{result.health.reason ?? VERDICTS[result.health.verdict].blurb}
</p>
</div>
</div>
<div className="border-t border-white/10 px-4 py-2">
<Row label="Control plane" value={result.health.connected ? 'answering' : 'not answering'} />
{result.health.probe && <Row label="Probe" value={result.health.probe} />}
{result.health.container && <ContainerFacts container={result.health.container} />}
</div>
<Evidence health={result.health} />
<Lifecycle running={result.health.container?.running ?? null} />
</Card>
<LogViewer />
</>
) : null}
</div>
</ViewShell>
);
};
+75
View File
@@ -0,0 +1,75 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
//
// Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
// being derived in JS, per the navigation audit's Phase 4.
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
servers: Server,
nodes: Laptop,
users: Users,
keys: KeyRound,
invites: Smartphone,
policy: ShieldCheck,
diagnostics: Activity,
console: TerminalSquare,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
type SectionBodyProps = { icon: LucideIcon; label: string; selected: boolean };
const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => (
<>
{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}
</>
);
export const HeadscaleNav = () => {
const { active } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<nav className="flex flex-col gap-0.5 px-2 py-3">
{HEADSCALE_SECTIONS.map(({ id, label }) => {
// Without an active server the domain sections have nothing to act on, so they are rendered as
// plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
// useful is worse than no link.
if (id !== 'servers' && !active) {
return (
<span key={id} title="Select a server first" className={`${ROW} cursor-default opacity-40`}>
<SectionBody icon={ICONS[id]} label={label} selected={false} />
</span>
);
}
return (
<NavLink
key={id}
to={headscaleSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => <SectionBody icon={ICONS[id]} label={label} selected={isActive} />}
</NavLink>
);
})}
</nav>
</div>
);
};
@@ -0,0 +1,60 @@
import { Check, Network, Plus } from 'lucide-react';
import { Link } from 'react-router';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Top-left panel of the /headscale workspace: which server everything else acts on.
//
// It is its own panel rather than a block inside HeadscaleNav because the two answer different questions —
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the
// section list below is real links.
//
// Every registered server is listed, including when there is only one: the panel's whole job is to say what
// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible.
export const HeadscaleServerPicker = () => {
const { servers, active, activate, isLoading } = 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>
<div className="flex flex-col gap-0.5 px-2 pb-3">
{servers.map((server) => (
<button
key={server.id}
type="button"
onClick={() => !server.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 ${
server.isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{server.name}</span>
</button>
))}
{servers.length === 0 && !isLoading && (
<Link
to={headscaleSectionPath('servers')}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5 shrink-0" />
Register a server
</Link>
)}
</div>
</div>
);
};
+37
View File
@@ -0,0 +1,37 @@
import { useHeadscaleSection } from './useHeadscaleSection';
import { ServersView } from './ServersView';
import { NodesView } from './NodesView';
import { UsersView } from './UsersView';
import { KeysView } from './KeysView';
import { InvitesView } from './InvitesView';
import { PolicyView } from './PolicyView';
import { DiagnosticsView } from './DiagnosticsView';
import { ConsoleView } from './ConsoleView';
// Right panel of the /headscale workspace — renders the section named by the URL.
//
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
// itself through ViewShell, so there is no gating to do here.
export const HeadscaleView = () => {
const section = useHeadscaleSection();
switch (section) {
case 'nodes':
return <NodesView />;
case 'users':
return <UsersView />;
case 'keys':
return <KeysView />;
case 'invites':
return <InvitesView />;
case 'policy':
return <PolicyView />;
case 'diagnostics':
return <DiagnosticsView />;
case 'console':
return <ConsoleView />;
default:
return <ServersView />;
}
};
@@ -0,0 +1,23 @@
import { Network } from 'lucide-react';
import { useHeadscaleServers } from './useHeadscaleServers';
import { HEADSCALE_SECTIONS } from './shared';
import { useHeadscaleSection } from './useHeadscaleSection';
// 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 = useHeadscaleSection();
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>
</>
);
};
+394
View File
@@ -0,0 +1,394 @@
import { useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react';
import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared';
import { INVITE_TTL_DEFAULT_SECONDS } from './shared';
import { useHeadscaleInvites } from './useHeadscaleInvites';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { fullDate, timeAgo, timeUntil } from './format';
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
import { EmptyBody, ViewShell } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5.
//
// The point of the feature is that the person joining does nothing but tap a link and press Join: no app
// store hunt, no control-server URL typed by hand, no pre-auth key they have no way to generate. The admin
// does all of it here and sends one link.
//
// TWO RULES SHAPE THIS FILE.
//
// 1. The link exists exactly once. Its fragment carries the claim token, and §5 is explicit: never display,
// log or store it beyond the moment it is handed to the admin. So the created invite lives in component
// state only — never in the query cache, never in a URL, never in a toast that outlives the panel — and
// the panel drops it on dismiss. Refreshing the page is meant to lose it; the admin mints another.
// 2. The token is not the key. Nothing here can join a machine to the tailnet: the pre-auth key is minted
// by the server at claim time. A leaked link before it is claimed is revocable, which is the whole
// reason the credential is not in the URL.
const STATUS_TONE: Record<InviteStatus, 'ok' | 'warn' | 'bad' | 'idle'> = {
pending: 'warn',
claimed: 'ok',
expired: 'idle',
revoked: 'bad',
};
/** Presets rather than a free number: every one is inside the spec's 60s24h range by construction. */
const TTL_OPTIONS = [
{ seconds: 300, label: '5 minutes' },
{ seconds: INVITE_TTL_DEFAULT_SECONDS, label: '15 minutes' },
{ seconds: 3600, label: '1 hour' },
{ seconds: 86_400, label: '24 hours' },
] as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void copyToClipboard(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
return (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
/**
* The QR, rendered client-side into a canvas.
*
* It never leaves the browser — an image endpoint would put the claim token in a request line and therefore
* in a server log, which is the exact thing the fragment-only link format exists to prevent. Error
* correction stays low so the modules stay large: this is scanned from a phone held next to the screen, not
* printed and posted.
*/
const InviteQr = ({ url }: { url: string }) => {
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvas.current) return;
void QRCode.toCanvas(canvas.current, url, { errorCorrectionLevel: 'L', margin: 2, width: 220 });
}, [url]);
return <canvas ref={canvas} className="h-[220px] w-[220px] rounded-lg bg-white p-1" />;
};
type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void };
const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
const [showQr, setShowQr] = useState(true);
// Plain https now — the link lands on a page the companion serves, which bounces into the app. It goes in
// `url` rather than `text` so share targets treat it as a link and preserve the fragment. A cancelled sheet
// rejects — nothing to report there, the link is still on screen.
const share = () => {
void navigator
.share?.({ title: 'Join the tailnet', text: `Tap to join as ${invite.user}`, url: invite.url })
.catch(() => {});
};
return (
<div className="overflow-hidden rounded-xl border border-primary/30 bg-primary/[0.06]">
<div className="flex items-start gap-2.5 border-b border-primary/20 px-4 py-3">
<Smartphone className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-100">Send this link to the device</div>
<p className="mt-0.5 text-xs leading-snug text-zinc-400">
It opens OffScale, shows one confirmation screen and joins as{' '}
<span className="text-zinc-200">{invite.user}</span>. Single use, and it stops working{' '}
{timeUntil(invite.expiresAt ?? null)}. Officer does not keep a copy dismiss this and it is gone.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-300">
{invite.url}
</div>
{showQr && (
<div className="flex justify-center py-1">
<InviteQr url={invite.url} />
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={invite.url} label="Copy link" />
{/* Only where the OS actually has a share sheet — a button that silently does nothing is worse
than no button, and on desktop Chrome/Firefox navigator.share is simply absent. */}
{typeof navigator.share === 'function' && (
<Button onClick={share}>
<Share2 className="h-3.5 w-3.5" />
Share
</Button>
)}
<Button onClick={() => setShowQr((v) => !v)}>
<QrCode className="h-3.5 w-3.5" />
{showQr ? 'Hide QR' : 'Show QR'}
</Button>
<Button variant="danger" onClick={onDismiss}>
Done
</Button>
</div>
</div>
</div>
);
};
type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void };
const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
const { users } = useHeadscaleUsers();
const { create } = useHeadscaleInvites();
const [user, setUser] = useState('');
const [note, setNote] = useState('');
const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS);
const [ephemeral, setEphemeral] = useState(false);
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
// The invite API names the Headscale USER, not its uint64 id — the server it is sent to may not be the
// one this list came from by the time it is claimed.
const chosen = user || users[0]?.name;
if (!chosen) return setError('Create a user first — an invite files the joining device under one.');
try {
const invite = await create.mutateAsync({
user: chosen,
ttlSeconds: ttl,
ephemeral,
note: note.trim(),
tags: tags
.split(/[\s,]+/)
.map((t) => t.trim())
.filter(Boolean),
});
onCreated(invite);
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">Authorize a new device</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={user || users[0]?.name || ''}
onChange={(ev) => setUser(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((entry) => (
<option key={entry.id} value={entry.name}>
{entry.name}
</option>
))}
</select>
</label>
<Field
label="Device name (optional)"
value={note}
onChange={setNote}
placeholder="andre-iphone"
hint="Prefilled on the phone's join screen and used as the node's name, which the person can edit. It labels this invite in your list too."
/>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">Link valid for</span>
<select
value={ttl}
onChange={(ev) => setTtl(Number(ev.target.value))}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{TTL_OPTIONS.map((option) => (
<option key={option.seconds} value={option.seconds}>
{option.label}
</option>
))}
</select>
<span className="text-[11px] leading-snug text-zinc-600">
How long the link can be claimed for. Short is safer you can always mint another.
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={ephemeral}
onChange={(ev) => setEphemeral(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">Ephemeral</span>
<span className="block text-[11px] leading-snug text-zinc-600">
The node is removed when it goes offline. Wrong for a phone; right for a container.
</span>
</span>
</label>
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="phone, family"
hint="Comma or space separated. The tag: prefix is added for you, and the device cannot change them."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create invite
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void };
const InviteRow = ({ invite, onError }: InviteRowProps) => {
const { revoke } = useHeadscaleInvites();
const [confirming, setConfirming] = useState(false);
const run = async () => {
try {
await revoke.mutateAsync(invite.id);
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[invite.status] ?? 'idle'} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm text-zinc-200">{invite.note || 'Untitled invite'}</span>
<Badge>{invite.user}</Badge>
{invite.ephemeral && <Badge>ephemeral</Badge>}
{invite.tags?.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{invite.status}</span>
{invite.status === 'pending' && (
<span title={fullDate(invite.expiresAt ?? null)}>· expires {timeUntil(invite.expiresAt ?? null)}</span>
)}
{invite.status === 'claimed' && (
<span title={fullDate(invite.claimedAt ?? null)}>
· claimed {timeAgo(invite.claimedAt ?? null)}
{invite.claimedFromIp ? ` from ${invite.claimedFromIp}` : ''}
</span>
)}
<span>· created {timeAgo(invite.createdAt ?? null)}</span>
</div>
</div>
{/* Revoking a claimed invite does nothing to the node that used it — that is a separate removal in
Nodes, and conflating the two here would make "revoke" mean two different things. */}
{invite.status === 'pending' && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{confirming ? (
<>
<Button variant="danger" onClick={() => void run()} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Confirm revoke
</Button>
<Button onClick={() => setConfirming(false)} disabled={revoke.isPending}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Revoke
</Button>
)}
</div>
)}
</div>
</Card>
);
};
export const InvitesView = () => {
const { invites, unavailable, isLoading, error } = useHeadscaleInvites();
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<HeadscaleInviteCreated | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const pending = invites.filter((i) => i.status === 'pending').length;
return (
<ViewShell isLoading={isLoading} error={error} label="device invites">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Device invites</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{unavailable ? 'Not available on this server' : `${pending} pending of ${invites.length}`}
</p>
</div>
{!creating && !unavailable && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
Authorize new device
</Button>
)}
</div>
{/* Most registered servers have no enrolment API, and that is a normal state — the rest of the
Headscale sections work regardless, so this must not read as a broken screen. */}
{unavailable && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
title="This server cannot mint invites"
hint={`${unavailable}. Invites are served by the Officer Companion next to Headscale, because the joining phone has to reach it without an Officer account. Until it is deployed, use a pre-auth key.`}
/>
)}
{created && <InviteLinkPanel invite={created} onDismiss={() => setCreated(null)} />}
{creating && <CreateInviteForm onCreated={setCreated} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{!unavailable && invites.length === 0 && !creating && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
title="No invites yet"
hint="An invite is a link you send to whoever needs to join. They tap it, confirm once, and they are on the tailnet — no key to paste and nothing to configure."
/>
)}
{invites.map((invite) => (
<InviteRow key={invite.id} invite={invite} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+341
View File
@@ -0,0 +1,341 @@
import { useState } from 'react';
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
import type { HeadscalePreAuthKey } from './shared';
import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData';
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Pre-auth keys — the tokens a machine presents to join the tailnet.
//
// The whole screen is shaped by one fact: Headscale hashes keys, so the secret exists exactly once, in the
// create response. If this view renders it into a list, or lets a re-render drop it, the key is gone and the
// owner has to make another. Hence the show-once panel: it holds the secret in local state, offers the copy
// and the ready-to-paste join command, and only clears on an explicit dismiss.
//
// The list defaults to active keys because a long-lived server accumulates hundreds of spent ones.
const STATUS_FILTERS = [
{ id: 'active', label: 'Active' },
{ id: 'all', label: 'All' },
] as const;
type StatusFilter = (typeof STATUS_FILTERS)[number]['id'];
const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void copyToClipboard(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
return (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void };
const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => {
const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`;
return (
<div className="overflow-hidden rounded-xl border border-amber-500/30 bg-amber-500/[0.07]">
<div className="flex items-start gap-2.5 border-b border-amber-500/20 px-4 py-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-200">Copy this key now</div>
<p className="mt-0.5 text-xs leading-snug text-amber-200/70">
Headscale stores it hashed. Once you dismiss this, nothing not Officer, not the server can show it
again.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Key</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-100">
{secret}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Join command</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-400">
{command}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={secret} label="Copy key" />
<CopyButton value={command} label="Copy command" />
<Button variant="danger" onClick={onDismiss}>
I've saved it
</Button>
</div>
</div>
</div>
);
};
type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string };
const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={checked}
onChange={(ev) => onChange(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">{label}</span>
<span className="block text-[11px] leading-snug text-zinc-600">{hint}</span>
</span>
</label>
);
type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void };
const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
const { users } = useHeadscaleUsers();
const { create } = useHeadscaleKeys();
const [userId, setUserId] = useState('');
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [days, setDays] = useState('90');
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
const chosen = userId || users[0]?.id;
if (!chosen) return setError('Create a user first every key belongs to one.');
const expirationDays = Number(days);
if (!Number.isFinite(expirationDays) || expirationDays <= 0)
return setError('Expiry must be a positive number of days');
try {
const result = await create.mutateAsync({
userId: chosen,
reusable,
ephemeral,
expirationDays,
aclTags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
});
if (result.key.key) onCreated(result.key.key);
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">New pre-auth key</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={userId || users[0]?.id || ''}
onChange={(ev) => setUserId(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
<div className="flex flex-col gap-2">
<Toggle
checked={reusable}
onChange={setReusable}
label="Reusable"
hint="Any number of machines can join with it, until it expires."
/>
<Toggle
checked={ephemeral}
onChange={setEphemeral}
label="Ephemeral"
hint="Nodes that join with it are removed when they go offline. For containers and CI."
/>
</div>
<Field label="Expires in (days)" value={days} onChange={setDays} placeholder="90" />
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="server, ci"
hint="Comma separated. The tag: prefix is added for you."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create key
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void };
const KeyRow = ({ entry, onError }: KeyRowProps) => {
const { expire, remove } = useHeadscaleKeys();
const [confirming, setConfirming] = useState(false);
const busy = expire.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[entry.status]} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-xs text-zinc-300">{entry.keyDisplay}</span>
{entry.user && <Badge>{entry.user.name}</Badge>}
{entry.reusable && <Badge>reusable</Badge>}
{entry.ephemeral && <Badge>ephemeral</Badge>}
{entry.aclTags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{entry.status}</span>
<span title={fullDate(entry.expiration)}>· expires {timeUntil(entry.expiration)}</span>
<span>· created {timeAgo(entry.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{entry.status === 'active' && (
<Button onClick={() => void run(() => expire.mutateAsync(entry.id))} disabled={busy} title="Expire now">
<TimerOff className="h-3.5 w-3.5" />
Expire
</Button>
)}
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(entry.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm delete
</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" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
export const KeysView = () => {
const { keys, isLoading, error } = useHeadscaleKeys();
const { active } = useHeadscaleServers();
const [creating, setCreating] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const [filter, setFilter] = useState<StatusFilter>('active');
const [actionError, setActionError] = useState<string | null>(null);
const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active');
const activeCount = keys.filter((k) => k.status === 'active').length;
return (
<ViewShell isLoading={isLoading} error={error} label="pre-auth keys">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Pre-auth keys</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{activeCount} active of {keys.length}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 p-0.5">
{STATUS_FILTERS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setFilter(option.id)}
className={`cursor-pointer rounded-md px-2 py-1 text-[11px] transition-colors ${
filter === option.id ? 'bg-white/10 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
{option.label}
</button>
))}
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New key
</Button>
)}
</div>
</div>
{secret && <SecretPanel secret={secret} loginServer={active?.url ?? ''} onDismiss={() => setSecret(null)} />}
{creating && <CreateKeyForm onCreated={setSecret} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{keys.length === 0 && !creating && (
<EmptyBody
icon={<KeyRound className="h-6 w-6" />}
title="No pre-auth keys"
hint="A key lets a machine join without an interactive login. Create one, then run the join command it gives you."
/>
)}
{keys.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">
No active keys. Switch to All to see spent and expired ones.
</div>
)}
{visible.map((entry) => (
<KeyRow key={entry.id} entry={entry} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+436
View File
@@ -0,0 +1,436 @@
import { useState } from 'react';
import {
Laptop,
Globe,
Trash2,
Pencil,
TimerReset,
Check,
X,
Search,
Copy,
ChevronRight,
UserRound,
ArrowRightLeft,
Tag as TagIcon,
} from 'lucide-react';
import type { HeadscaleNode } from './shared';
import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// The nodes section — the machines in the tailnet.
//
// Route approval is the only genuinely dangerous control here, so it is explicit: every route the node
// ADVERTISES is listed, each with its own approve/revoke toggle, and an exit node is called what it is
// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved
// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets.
const copy = (text: string) => void copyToClipboard(text);
type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void };
const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => {
const isExit = route === '0.0.0.0/0' || route === '::/0';
return (
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] px-2.5 py-1.5">
{isExit ? <Globe className="h-3.5 w-3.5 shrink-0 text-amber-400" /> : <Dot tone={approved ? 'ok' : 'idle'} />}
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-zinc-300">{route}</span>
{isExit && <span className="shrink-0 text-[10px] uppercase tracking-wide text-amber-400/80">exit node</span>}
<button
type="button"
disabled={busy}
onClick={() => onToggle(!approved)}
className={`shrink-0 cursor-pointer rounded-md border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
approved
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
: 'border-white/10 text-zinc-400 hover:bg-white/10 hover:text-zinc-100'
}`}
>
{approved ? 'Approved' : 'Approve'}
</button>
</div>
);
};
/**
* Tags as Headscale stores them: every one prefixed `tag:`. Typing the prefix every time is noise, so the
* editor accepts either form and normalizes here — which is also how the dirty check stays honest, since
* `web` and `tag:web` are the same tag and neither should look like an edit.
*/
const parseTags = (text: string): string[] => {
const parts = text
.split(/[\s,]+/)
.map((t) => t.trim())
.filter(Boolean);
return [...new Set(parts.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)))];
};
/** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */
const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t));
type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void };
/**
* Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit
* together behind the disclosure rather than next to Rename.
*
* Mounted only while the card is expanded: it needs the user list, and fetching every user to render a
* collapsed row would be a request per screenful for a control nobody is looking at. The query key is
* shared with the Users section, so an expanded card is usually a cache hit anyway.
*/
const Ownership = ({ node, busy, onError }: OwnershipProps) => {
const { setTags, moveToUser } = useHeadscaleNodes();
const { users } = useHeadscaleUsers();
const [owner, setOwner] = useState(node.user?.id ?? '');
const [draftTags, setDraftTags] = useState(node.tags.join(' '));
const pending = setTags.isPending || moveToUser.isPending;
const nextTags = parseTags(draftTags);
const tagsDirty = !sameTags(nextTags, node.tags);
const ownerDirty = !!owner && owner !== node.user?.id;
const target = users.find((u) => u.id === owner);
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<div className="flex flex-col gap-2.5">
<div className="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Owner and tags</div>
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<select
value={owner}
onChange={(ev) => setOwner(ev.target.value)}
disabled={busy || pending}
className="min-w-0 flex-1 cursor-pointer rounded-md border border-white/10 bg-black/40 px-2 py-1 text-xs text-zinc-200 outline-none focus:border-primary/50 disabled:opacity-40"
>
{!node.user && <option value="">no owner</option>}
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{ownerDirty && (
<>
<Button
onClick={() => void run(() => moveToUser.mutateAsync({ id: node.id, userId: owner }))}
disabled={busy || pending}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
Move
</Button>
<Button onClick={() => setOwner(node.user?.id ?? '')} disabled={busy || pending}>
Cancel
</Button>
</>
)}
</div>
{/* Said before the move, not after: the node keeps its address and its tags, but the rules that let
anything reach it are written per user, so it can go dark to everything that used to see it. */}
{ownerDirty && (
<p className="text-[11px] leading-snug text-amber-400/90">
Moving this node to <span className="font-medium">{target?.name ?? 'another user'}</span> changes which policy
rules apply to it. Its addresses and tags stay, but anything reaching it through a rule written for{' '}
{node.user?.name ?? 'its current owner'} will stop.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<TagIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<input
value={draftTags}
onChange={(ev) => setDraftTags(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && tagsDirty) void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }));
if (ev.key === 'Escape') setDraftTags(node.tags.join(' '));
}}
placeholder="tag:server tag:eu — space separated"
spellCheck={false}
autoComplete="off"
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 font-mono text-[11px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-primary/50"
/>
{tagsDirty && (
<>
<Button
onClick={() => void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }))}
disabled={busy || pending}
>
<Check className="h-3.5 w-3.5" />
Apply tags
</Button>
<Button onClick={() => setDraftTags(node.tags.join(' '))} disabled={busy || pending}>
Revert
</Button>
</>
)}
</div>
<p className="text-[11px] leading-snug text-zinc-600">
Tags are what the access policy targets. A tag no rule mentions does nothing; removing one a rule depends on
cuts the node off from it. The <span className="text-zinc-500">tag:</span> prefix is added for you.
</p>
</div>
);
};
type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void };
const NodeCard = ({ node, onError }: NodeCardProps) => {
const { rename, toggleRoute, expire, remove } = useHeadscaleNodes();
const [open, setOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draftName, setDraftName] = useState(node.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || toggleRoute.isPending || expire.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draftName.trim();
setRenaming(false);
if (!name || name === node.name) return;
await run(() => rename.mutateAsync({ id: node.id, name }));
};
return (
<Card>
<div className="flex flex-col">
<div className="flex items-center gap-2.5 px-3.5 py-3">
<Dot tone={node.online ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draftName}
onChange={(ev) => setDraftName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submitRename();
if (ev.key === 'Escape') setRenaming(false);
}}
autoFocus
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50"
/>
<button
type="button"
onClick={() => void submitRename()}
className="cursor-pointer p-1 text-emerald-400"
>
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">
<span className="tabular-nums text-zinc-500">{node.id}:</span> {node.hostname}{' '}
<span className="font-normal text-zinc-500">({node.name})</span>
</span>
{node.user && <Badge>{node.user.name}</Badge>}
{node.isExitNode && <Badge>exit</Badge>}
{node.tags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span className="font-mono">{node.ipAddresses[0] ?? 'no address'}</span>
<span>· {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`}</span>
{node.subnetRoutes.length > 0 && <span>· {node.subnetRoutes.length} route(s) active</span>}
</div>
</div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Collapse' : 'Expand'}
className="shrink-0 cursor-pointer p-1 text-zinc-500 transition-colors hover:text-zinc-200"
>
<ChevronRight className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
</div>
{open && (
<div className="flex flex-col gap-3 border-t border-white/10 bg-black/30 p-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div className="text-zinc-500">Addresses</div>
<div className="flex flex-col gap-0.5">
{node.ipAddresses.map((ip) => (
<button
key={ip}
type="button"
onClick={() => copy(ip)}
title="Copy"
className="group flex cursor-pointer items-center gap-1 text-left font-mono text-zinc-300"
>
{ip}
<Copy className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-60" />
</button>
))}
</div>
<div className="text-zinc-500">Hostname</div>
<div className="truncate font-mono text-zinc-300">{node.hostname}</div>
<div className="text-zinc-500">Registered</div>
<div className="text-zinc-300">
{timeAgo(node.createdAt)} · {node.registerMethod}
</div>
<div className="text-zinc-500">Key expires</div>
<div className="text-zinc-300" title={fullDate(node.expiry)}>
{timeUntil(node.expiry)}
</div>
<div className="text-zinc-500">Last seen</div>
<div className="text-zinc-300" title={fullDate(node.lastSeen)}>
{node.online ? 'now' : timeAgo(node.lastSeen)}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Advertised routes
</div>
{node.availableRoutes.length === 0 ? (
<div className="text-[11px] text-zinc-600">This node advertises no routes.</div>
) : (
<div className="flex flex-col gap-1">
{node.availableRoutes.map((route) => (
<RouteRow
key={route}
route={route}
approved={node.approvedRoutes.includes(route)}
busy={busy}
onToggle={(approved) => void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))}
/>
))}
</div>
)}
</div>
<Ownership node={node} busy={busy} onError={onError} />
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraftName(node.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
<Button
onClick={() => void run(() => expire.mutateAsync(node.id))}
disabled={busy}
title="Expire the node's key — it stays registered but must re-authenticate"
>
<TimerReset className="h-3.5 w-3.5" />
Force re-auth
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(node.id))} 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>
)}
</div>
</Card>
);
};
export const NodesView = () => {
const { nodes, isLoading, error } = useHeadscaleNodes();
const [filter, setFilter] = useState('');
const [actionError, setActionError] = useState<string | null>(null);
const needle = filter.trim().toLowerCase();
const visible = needle
? nodes.filter(
(n) =>
n.id === needle ||
n.name.toLowerCase().includes(needle) ||
n.hostname.toLowerCase().includes(needle) ||
n.user?.name.toLowerCase().includes(needle) ||
n.ipAddresses.some((ip) => ip.includes(needle)) ||
n.tags.some((t) => t.toLowerCase().includes(needle)),
)
: nodes;
const online = nodes.filter((n) => n.online).length;
return (
<ViewShell isLoading={isLoading} error={error} label="nodes">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
<div className="flex items-center gap-3 px-1 pb-1">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-zinc-100">Nodes</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{nodes.length} registered · {online} online
</p>
</div>
<div className="relative w-56 shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-600" />
<input
value={filter}
onChange={(ev) => setFilter(ev.target.value)}
placeholder="Filter by id, name, user, IP, tag"
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-black/40 py-1.5 pl-8 pr-2.5 text-xs text-zinc-100 outline-none placeholder:text-zinc-600 focus:border-primary/50"
/>
</div>
</div>
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{nodes.length === 0 && (
<EmptyBody
icon={<Laptop className="h-6 w-6" />}
title="No nodes yet"
hint="Create a pre-auth key and run `tailscale up --login-server <your server> --authkey <key>` on a machine to join it."
/>
)}
{nodes.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">Nothing matches {filter}.</div>
)}
{visible.map((node) => (
<NodeCard key={node.id} node={node} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+177
View File
@@ -0,0 +1,177 @@
import { useMemo, useState } from 'react';
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy';
import { collapseUnchanged, diffCounts, diffLines } from './diff';
import { Button, Card, ErrorNote } from './Cards';
// Ask for a policy change in English; read the diff; decide.
//
// The whole point of this panel is the middle step. The model is good at the grammar — HuJSON, tagOwners,
// the src/dst shapes — and has no idea which of the owner's machines matter, so its proposal is a draft to
// be read, not an answer to be trusted. Nothing here writes to Headscale: Apply puts the text in the editor
// above and the existing Save button is still the only thing that leaves the browser.
//
// The diff is against what is CURRENTLY in the editor, which is also what was sent up, so it always shows
// exactly what accepting would change on screen — including edits the owner made and hasn't saved.
const EXAMPLES = [
'let everyone reach the machines tagged tag:server on port 22',
'stop the phones from reaching anything except the DNS server',
'add a group for family with just my own user in it',
];
const DiffBody = ({ before, after }: { before: string; after: string }) => {
const lines = useMemo(() => diffLines(before, after), [before, after]);
const rows = useMemo(() => collapseUnchanged(lines), [lines]);
const { added, removed } = useMemo(() => diffCounts(lines), [lines]);
if (!added && !removed) {
return <p className="px-3 py-2.5 text-[11px] text-zinc-500">No change the proposal matches what you have.</p>;
}
return (
<>
<div className="flex items-center gap-3 border-b border-white/10 px-3 py-1.5 text-[11px]">
<span className="text-emerald-400">+{added}</span>
<span className="text-red-400">{removed}</span>
<span className="text-zinc-600">unchanged lines collapsed</span>
</div>
<div className="max-h-72 overflow-auto p-1 font-mono text-[11px] leading-relaxed">
{rows.map((row, index) =>
row === null ? (
<div key={index} className="px-2 py-1 text-center text-zinc-700 select-none">
</div>
) : (
<div
key={index}
className={`px-2 whitespace-pre-wrap ${
row.kind === 'add'
? 'bg-emerald-500/10 text-emerald-300'
: row.kind === 'remove'
? 'bg-red-500/10 text-red-300'
: 'text-zinc-500'
}`}
>
{row.kind === 'add' ? '+' : row.kind === 'remove' ? '' : ' '} {row.text}
</div>
),
)}
</div>
</>
);
};
type PolicyAssistantProps = {
/** The text on screen right now. Sent up as the base, and diffed against. */
policy: string;
/** Accepting a proposal — puts it in the editor's draft. Never saves. */
onApply: (policy: string) => void;
disabled?: boolean;
};
export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => {
const assist = useHeadscalePolicyAssist();
const [prompt, setPrompt] = useState('');
// The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and
// a second ask doesn't briefly show the previous answer against the new base.
const [proposal, setProposal] = useState<{ explanation: string; policy: string } | null>(null);
const ask = async () => {
const request = prompt.trim();
if (!request || assist.isPending) return;
setProposal(null);
try {
setProposal(await assist.mutateAsync({ prompt: request, policy }));
} catch {
// Rendered from `assist.error` below — mutateAsync rejecting is the same failure twice.
}
};
const apply = () => {
if (!proposal) return;
onApply(proposal.policy);
setProposal(null);
setPrompt('');
assist.reset();
};
return (
<Card>
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-zinc-200">Describe the change</span>
<span className="ml-auto text-[11px] text-zinc-600">Proposes a document never saves it</span>
</div>
<div className="flex flex-col gap-2 p-3">
<textarea
value={prompt}
onChange={(ev) => setPrompt(ev.target.value)}
onKeyDown={(ev) => {
// Enter sends: this is a one-line instruction far more often than a paragraph, and shift-enter
// is still there for the times it isn't.
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
void ask();
}
}}
rows={2}
spellCheck={false}
disabled={disabled}
placeholder="e.g. give my laptop SSH access to everything tagged tag:server"
className="w-full resize-y rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm leading-relaxed text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50 disabled:opacity-50"
/>
<div className="flex flex-wrap items-center gap-2">
{!prompt.trim() &&
EXAMPLES.map((example) => (
<button
key={example}
type="button"
onClick={() => setPrompt(example)}
disabled={disabled}
className="cursor-pointer rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-500 transition-colors hover:border-white/20 hover:text-zinc-300 disabled:opacity-40"
>
{example}
</button>
))}
<div className="ml-auto">
<Button variant="primary" onClick={() => void ask()} disabled={!prompt.trim() || assist.isPending}>
{assist.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
{assist.isPending ? 'Drafting…' : 'Ask'}
</Button>
</div>
</div>
{assist.error && <ErrorNote>{assistFailure(assist.error)}</ErrorNote>}
</div>
{proposal && (
<div className="border-t border-white/10">
{proposal.explanation && (
<p className="px-3 py-2.5 text-xs leading-relaxed whitespace-pre-wrap text-zinc-300">
{proposal.explanation}
</p>
)}
<div className="border-t border-white/10">
<DiffBody before={policy} after={proposal.policy} />
</div>
<div className="flex items-center gap-2 border-t border-white/10 px-3 py-2">
<span className="text-[11px] text-zinc-600">Applying only fills the editor you still press Save.</span>
<span className="ml-auto flex items-center gap-2">
<Button onClick={() => setProposal(null)}>
<X className="h-3.5 w-3.5" />
Discard
</Button>
<Button variant="primary" onClick={apply}>
<Check className="h-3.5 w-3.5" />
Apply to editor
</Button>
</span>
</div>
</div>
)}
</Card>
);
};
+210
View File
@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
import { timeAgo } from './format';
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
import { PolicyAssistant } from './PolicyAssistant';
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// The tailnet's ACL document. A plain textarea on purpose — this is HuJSON, where the comments and the
// hand-kept alignment are half the document's value to whoever maintains it, and a rich editor that
// reformats or a client-side parser that disagrees with Headscale would both destroy more than they add.
//
// It opens READ-ONLY behind an Edit button. This is the document that decides which machine can reach
// which, it is usually being looked at rather than changed, and a textarea focused by a stray click is a
// way to alter it without meaning to. Edit mode also brings up the assistant, because "I do not know what
// this file should look like" is the actual reason this screen was hard to use.
//
// Validation is entirely Headscale's. It has the only parser that counts: it resolves groups, tags and
// host aliases, and it is what will actually enforce the result. Officer sends the text up untouched and
// shows the verdict verbatim — including the line and column, which is the whole reason to show it at all.
//
// Two failures, deliberately styled differently. A REJECTED document is a normal part of editing and stays
// inline next to the save button. A READ-ONLY server means this screen cannot do its job at all and says so
// at the top, permanently, because the owner needs to go and edit a file on the server instead.
/** Ctrl/Cmd-S while the textarea has focus. An ACL is long enough that reaching for the button breaks flow. */
function useSaveShortcut(onSave: () => void, enabled: boolean) {
const handler = useRef(onSave);
handler.current = onSave;
useEffect(() => {
if (!enabled) return;
const onKeyDown = (ev: KeyboardEvent) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') {
ev.preventDefault();
handler.current();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}
const ReadOnlyBanner = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200">
<FileLock2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-amber-100">This server's policy is read-only</div>
<p className="mt-1 text-amber-200/80">
Headscale said: <span className="font-mono">{message}</span>
</p>
<p className="mt-1.5 text-amber-200/70">
It is reading its policy from a file on disk rather than from its database, so the API refuses writes — a save
here would be overwritten on the next restart anyway. Edit the file on the server (the Console section is one
way in) and reload it there. Everything below is still the live document, and still readable.
</p>
</div>
</div>
);
const Rejected = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs leading-relaxed text-red-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-red-200">Headscale rejected this policy</div>
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
<pre className="mt-1 font-mono text-[11px] whitespace-pre-wrap text-red-300/90">{message}</pre>
<p className="mt-1.5 text-red-300/70">Nothing was saved — the tailnet is still running the previous policy.</p>
</div>
</div>
);
export const PolicyView = () => {
const { policy, isLoading, error, save } = useHeadscalePolicy();
const [draft, setDraft] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [failure, setFailure] = useState<PolicySaveFailure | null>(null);
// Sticky for the session: once a server has refused a write, every later save would refuse identically,
// and re-discovering that by pressing save again is not information.
const [readOnly, setReadOnly] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
// The fetched document seeds the editor once. After that the draft owns the text — a refetch must never
// reach in and replace what someone is typing.
const text = draft ?? policy?.policy ?? '';
const dirty = draft !== null && draft !== (policy?.policy ?? '');
const submit = async () => {
if (!dirty || readOnly || save.isPending) return;
setFailure(null);
try {
await save.mutateAsync(text);
setDraft(null);
setSavedAt(Date.now());
// A clean save is the end of the edit, not the start of the next one — back to reading.
setEditing(false);
} catch (err) {
const parsed = policySaveFailure(err);
setFailure(parsed);
if (parsed.kind === 'readOnly') setReadOnly(parsed.message);
}
};
useSaveShortcut(() => void submit(), editing && dirty && !readOnly);
const revert = () => {
setDraft(null);
setFailure(null);
};
/** Leaving edit mode throws the draft away — there is nowhere else for unsaved text to go. */
const stopEditing = () => {
revert();
setEditing(false);
};
return (
<ViewShell isLoading={isLoading} error={error} label="the access policy">
<div className="mx-auto flex max-w-4xl flex-col gap-3">
<SectionHeader
title="Access policy"
subtitle="HuJSON — JSON with comments and trailing commas. Headscale validates it on save; nothing is stored unless it passes."
action={
editing ? (
<div className="flex items-center gap-2">
<Button onClick={stopEditing} disabled={save.isPending}>
<RotateCcw className="h-3.5 w-3.5" />
{dirty ? 'Discard' : 'Done'}
</Button>
<Button
variant="primary"
onClick={() => void submit()}
disabled={!dirty || !!readOnly || save.isPending}
>
{save.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{save.isPending ? 'Validating' : 'Save'}
</Button>
</div>
) : (
<Button
onClick={() => setEditing(true)}
disabled={!!readOnly}
title={readOnly ? 'This server will not accept written policies' : undefined}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)
}
/>
{readOnly && <ReadOnlyBanner message={readOnly} />}
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
{editing && (
<PolicyAssistant
policy={text}
onApply={(proposed) => {
setDraft(proposed);
setFailure(null);
setSavedAt(null);
}}
disabled={save.isPending}
/>
)}
<Card>
<textarea
value={text}
onChange={(ev) => {
setDraft(ev.target.value);
setFailure(null);
setSavedAt(null);
}}
spellCheck={false}
autoComplete="off"
readOnly={!editing}
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
className={`block h-[28rem] w-full resize-y p-4 font-mono text-[12px] leading-relaxed outline-none placeholder:text-zinc-700 ${
editing ? 'bg-black/40 text-zinc-200' : 'bg-black/20 text-zinc-400'
}`}
/>
<div className="flex flex-wrap items-center gap-3 border-t border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<span>
{text.split('\n').length} lines · {text.length} characters
</span>
<span className="ml-auto flex items-center gap-3">
{savedAt !== null && !dirty && (
<span className="flex items-center gap-1 text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
Saved and accepted
</span>
)}
{dirty && <span className="text-amber-400">Unsaved changes</span>}
{policy?.updatedAt && <span>Last changed {timeAgo(policy.updatedAt)}</span>}
</span>
</div>
</Card>
<p className="px-1 text-[11px] leading-relaxed text-zinc-600">
This document decides which node may reach which. A policy that saves cleanly can still cut a machine off —
Headscale checks that the document is valid, not that it is what you meant.
{editing ? ' Ctrl/Cmd-S saves.' : ' Press Edit to change it.'}
</p>
</div>
</ViewShell>
);
};
+226
View File
@@ -0,0 +1,226 @@
import { useState } from 'react';
import { Loader2, Terminal, Check, X } from 'lucide-react';
import type { HeadscaleServer, HeadscaleSshTest } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, useHeadscaleSshTest, 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".
//
// The SSH host is the odd one out: it is NOT validated on save. A control server that is down is exactly when
// you want the console, so refusing to save the escape hatch because the machine is unreachable would be
// precisely backwards. Test is a separate, explicit button.
/** The host part of the control-server URL, for the "you have typed the same machine" warning. */
function urlHost(url: string): string | null {
try {
return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).hostname.toLowerCase();
} catch {
return null;
}
}
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
const { register, update } = useHeadscaleServers();
const sshTest = useHeadscaleSshTest();
const editing = !!server;
const [name, setName] = useState(server?.name ?? '');
const [url, setUrl] = useState(server?.url ?? '');
const [apiKey, setApiKey] = useState('');
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
const [error, setError] = useState<string | null>(null);
const [sshResult, setSshResult] = useState<HeadscaleSshTest | null>(null);
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.
const sameAsControl = !!sshHost.trim() && !!urlHost(url) && sshTarget(sshHost) === urlHost(url);
const runSshTest = async () => {
setSshResult(null);
try {
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
} catch (err) {
setSshResult({ ok: false, error: headscaleErrorMessage(err), ms: 0 });
}
};
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');
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,
// '' is meaningful here — it clears the console target — so this is sent whenever it differs.
sshHost: sshHost.trim() === (server.sshHost ?? '') ? undefined : sshHost.trim(),
});
} else {
await register.mutateAsync({
name: name.trim() || undefined,
url: url.trim(),
apiKey: apiKey.trim(),
sshHost: sshHost.trim() || undefined,
});
}
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={edit(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={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."
/>
<Field
label="Name (optional)"
value={name}
onChange={edit(setName)}
placeholder="defaults to the hostname"
hint="A label for switching between servers."
/>
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-3">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-300">
<Terminal className="h-3.5 w-3.5" />
SSH console (optional)
</div>
<p className="text-[11px] leading-snug text-zinc-500">
The last resort for when the API cannot answer Headscale crashed, the tailnet is down, the logs are the
only evidence. The Console section runs plain <code className="font-mono">ssh</code> here in a terminal,
using the keys already on this machine. Officer stores no password, key or port.
</p>
<Field
label="SSH address"
value={sshHost}
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."
/>
{sameAsControl && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
That is the same host as the server URL. If Headscale is what resolves or routes that name, the console
will be unreachable in exactly the situations you would need it. Prefer the machine's raw IP on a path
that does not depend on the tailnet.
</div>
)}
{sshResult && (
<div
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-[11px] leading-snug ${
sshResult.ok
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/30 bg-red-500/10 text-red-300'
}`}
>
{sshResult.ok ? (
<Check className="mt-px h-3.5 w-3.5 shrink-0" />
) : (
<X className="mt-px h-3.5 w-3.5 shrink-0" />
)}
<span>
{sshResult.ok ? (
<>Connected and ran a command in {sshResult.ms}ms.</>
) : (
<>
{sshResult.error ?? 'Could not connect'}
<span className="mt-1 block text-red-300/70">
The test never prompts, so a key that needs a passphrase, or one this machine does not have, fails
here as “Permission denied”.
</span>
</>
)}
</span>
</div>
)}
<div>
<Button onClick={() => void runSshTest()} disabled={!sshHost.trim() || sshTest.isPending}>
{sshTest.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Terminal className="h-3.5 w-3.5" />
)}
{sshTest.isPending ? 'Connecting' : 'Test connection'}
</Button>
</div>
</div>
{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>
);
};
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useRef, 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.
//
// EVERY server is probed when this section opens, in parallel, and again for any server registered while
// it is open. A probe costs two upstream round trips (an unauthenticated /version plus an authenticated
// call to prove the stored key still works) — cheap enough at this scale, and the alternative was worse:
// a grey "not checked" dot is the one thing this list must never show, because the reason to look at it
// is to find out which servers are up. A dot that says nothing makes the whole page say nothing.
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);
// Amber means "asking"; grey should only ever be the frame before the automatic probe starts.
const tone = health ? (health.ok ? 'ok' : 'bad') : testing ? '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>
);
};
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, refetch, activate, remove } = useHeadscaleServers();
const healthProbe = useHeadscaleHealth();
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
const test = async (id: number) => {
setTestingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setActionError(null);
try {
const result = await healthProbe.mutateAsync(id);
setHealth((prev) => ({ ...prev, [id]: result }));
} catch (err) {
// A probe that throws is still an answer about the server: record it as a red dot rather than as a
// page-level error, which would blame the whole screen for one unreachable box.
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
} finally {
setTestingIds((prev) => prev.filter((t) => t !== id));
}
};
// Probe every server once per visit to this section, and any server that appears while it is open. The
// ref is what makes "once" true: the list identity changes when a probe writes lastSeenAt, and without
// it each result would trigger the next round forever.
const probed = useRef(new Set<number>());
useEffect(() => {
for (const server of servers) {
if (probed.current.has(server.id)) continue;
probed.current.add(server.id);
void test(server.id);
}
}, [servers]);
const run = async (fn: () => Promise<unknown>) => {
setActionError(null);
try {
await fn();
} catch (err) {
setActionError(headscaleErrorMessage(err));
}
};
const busy = activate.isPending || remove.isPending;
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')} disabled={isLoading}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
)
}
/>
{/* 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. */}
{formFor && (
<ServerForm
key={formFor === 'new' ? 'new' : formFor.id}
server={formFor === 'new' ? null : formFor}
onClose={() => setFormFor(null)}
/>
)}
{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}
server={server}
health={health[server.id]}
testing={testingIds.includes(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>
);
};
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react';
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
import type { HeadscaleUserWithCounts } from './shared';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo } from './format';
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
// The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login.
//
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
const UserRow = ({ user, onError }: UserRowProps) => {
const { rename, remove } = useHeadscaleUsers();
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState(user.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draft.trim();
setRenaming(false);
if (!name || name === user.name) return;
await run(() => rename.mutateAsync({ id: user.id, name }));
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={user.onlineCount > 0 ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submitRename();
if (ev.key === 'Escape') setRenaming(false);
}}
autoFocus
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50"
/>
<button type="button" onClick={() => void submitRename()} className="cursor-pointer p-1 text-emerald-400">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{user.name}</span>
{user.provider && <Badge>{user.provider}</Badge>}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'}
{user.onlineCount > 0 && `, ${user.onlineCount} online`}
</span>
{user.email && <span>· {user.email}</span>}
<span>· created {timeAgo(user.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraft(user.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(user.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
{user.nodeCount > 0 ? `Delete with ${user.nodeCount} node(s)` : 'Confirm delete'}
</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" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
const { create } = useHeadscaleUsers();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
if (!name.trim()) return setError('A name is required');
try {
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
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">New user</div>
<Field
label="Name"
value={name}
onChange={setName}
placeholder="laptop-fleet"
hint="Lowercase, no spaces. This is the namespace nodes and keys belong to."
autoFocus
/>
<Field label="Email (optional)" value={email} onChange={setEmail} placeholder="someone@example.com" />
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create user
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
export const UsersView = () => {
const { users, isLoading, error } = useHeadscaleUsers();
const [creating, setCreating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
return (
<ViewShell isLoading={isLoading} error={error} label="users">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Users</h2>
<p className="mt-0.5 text-xs text-zinc-500">Namespaces that own nodes and pre-auth keys.</p>
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New user
</Button>
)}
</div>
{creating && <CreateUserForm onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{users.length === 0 && !creating && (
<EmptyBody
icon={<Users className="h-6 w-6" />}
title="No users yet"
hint="Every node belongs to a user. Create one before issuing a pre-auth key."
/>
)}
{users.map((user) => (
<UserRow key={user.id} user={user} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+77
View File
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import { Loader2, ServerOff } from 'lucide-react';
import { NO_ACTIVE_SERVER } from './shared';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { ErrorNote } from './Cards';
// The loading / no-server / failed states every domain section shares.
//
// "No active server" is a 409 carrying a `code`, deliberately not a 404 and deliberately not an empty
// list — an empty node table would read as "your tailnet is empty", which is a very different and much
// more alarming statement than "you haven't picked a server".
function isNoActiveServer(err: unknown): boolean {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string') return false;
try {
return (JSON.parse(raw) as { code?: unknown }).code === NO_ACTIVE_SERVER;
} catch {
return false;
}
}
type ViewShellProps = {
isLoading: boolean;
error: unknown;
/** What this section is called, for the loading and empty copy. */
label: string;
children: ReactNode;
};
export const ViewShell = ({ isLoading, error, label, children }: ViewShellProps) => {
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 {label}
</div>
);
}
if (error && isNoActiveServer(error)) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<ServerOff className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to see its {label}.</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<ErrorNote>
Could not load {label}: {headscaleErrorMessage(error)}
</ErrorNote>
</div>
);
}
return <div className="h-full overflow-y-auto p-4">{children}</div>;
};
/** Centred "nothing here yet" body for a section whose fetch succeeded but returned nothing. */
export const EmptyBody = ({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) => (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">{icon}</div>
<div>
<div className="text-base font-semibold text-zinc-100">{title}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">{hint}</p>
</div>
</div>
);
+77
View File
@@ -0,0 +1,77 @@
// A line diff, for showing what a proposed policy actually changes before anyone saves it.
//
// Hand-rolled rather than a dependency: this is one screen showing one document, the inputs are a few
// hundred lines at most, and the alternative is adding a package to the frozen lockfile for forty lines of
// well-understood algorithm. If a second surface ever needs a diff, that trade flips.
export type DiffLine = { kind: 'same' | 'add' | 'remove'; text: string };
/** Longest common subsequence table over the two line arrays. O(n·m) — fine at document scale. */
function lcsLengths(a: string[], b: string[]): number[][] {
const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
}
}
return table;
}
/** Every line of both documents, in order, tagged with what happened to it. */
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split('\n');
const b = after.split('\n');
const table = lcsLengths(a, b);
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
out.push({ kind: 'same', text: a[i]! });
i++;
j++;
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
out.push({ kind: 'remove', text: a[i]! });
i++;
} else {
out.push({ kind: 'add', text: b[j]! });
j++;
}
}
while (i < a.length) out.push({ kind: 'remove', text: a[i++]! });
while (j < b.length) out.push({ kind: 'add', text: b[j++]! });
return out;
}
/**
* Drop long runs of unchanged lines, keeping `context` either side of every change.
*
* A policy is mostly unchanged by any one edit, and an unabridged diff buries the three lines that matter.
* `null` marks each elision so the view can draw a gap rather than pretend the lines are adjacent.
*/
export function collapseUnchanged(lines: DiffLine[], context = 3): (DiffLine | null)[] {
const keep = new Array<boolean>(lines.length).fill(false);
lines.forEach((line, index) => {
if (line.kind === 'same') return;
for (let k = Math.max(0, index - context); k <= Math.min(lines.length - 1, index + context); k++) keep[k] = true;
});
const out: (DiffLine | null)[] = [];
let gap = false;
lines.forEach((line, index) => {
if (keep[index]) {
out.push(line);
gap = false;
} else if (!gap) {
out.push(null);
gap = true;
}
});
return out;
}
export const diffCounts = (lines: DiffLine[]) => ({
added: lines.filter((l) => l.kind === 'add').length,
removed: lines.filter((l) => l.kind === 'remove').length,
});
+35
View File
@@ -0,0 +1,35 @@
// Date formatting for the Headscale views. The sidecar already turned protobuf's zero timestamp into null,
// so null genuinely means "never" here and every helper says so rather than printing a fake date.
export function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 0) return 'just now';
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`;
const days = Math.round(hours / 24);
if (days < 365) return `${days}d ago`;
return `${Math.round(days / 365)}y ago`;
}
/** "in 3d" / "5h ago" — signed, for expiry dates that may be either side of now. */
export function timeUntil(iso: string | null): string {
if (!iso) return 'never';
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 0) return timeAgo(iso);
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `in ${Math.max(1, minutes)}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `in ${hours}h`;
return `in ${Math.round(hours / 24)}d`;
}
export function fullDate(iso: string | null): string {
if (!iso) return 'never';
return new Date(iso).toLocaleString();
}
+22
View File
@@ -0,0 +1,22 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'offscale-root',
direction: 'horizontal',
children: [
{
node: {
type: 'group',
id: 'headscale-sidebar',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'headscale-servers', appType: 'headscale-servers' }, size: 30 },
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 70 },
],
},
size: 22,
},
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
],
};
+27
View File
@@ -0,0 +1,27 @@
import type { AppRegistryMeta } from 'officerdev';
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
import { HeadscaleNav } from './HeadscaleNav';
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
import { HeadscaleView } from './HeadscaleView';
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
export { HeadscaleNav, HeadscaleServerPicker, HeadscaleView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'headscale-servers',
name: 'Headscale servers',
icon: Network,
component: HeadscaleServerPicker,
availableOnPanel: false,
},
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
{
key: 'headscale-view',
name: 'Headscale',
icon: LayoutGrid,
component: HeadscaleView,
header: HeadscaleViewHeader,
availableOnPanel: false,
},
];
+225
View File
@@ -0,0 +1,225 @@
// 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.
export const HEADSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
{ id: 'invites', label: 'Device invites' },
{ id: 'policy', label: 'Access policy' },
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'console', label: 'Console' },
] as const;
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
/** Where /headscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
HEADSCALE_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${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;
/**
* Where the Console section SSHes. Null when unset. Not derived from `url` on purpose — it exists to reach
* the machine when the control plane's own hostname has stopped answering.
*/
sshHost: 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;
};
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
export type HeadscaleSshTest = { ok: boolean; 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';
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
/**
* The tailnet's ACL document, in HuJSON (JSON with comments and trailing commas). Headscale serves it
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
* because there is nothing on the server that reports one. Only an attempted save finds out.
*/
export type HeadscalePolicy = {
policy: string;
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
updatedAt: string | null;
};
/** Headscale refused the write outright — this server's policy is read-only over the API. */
export const POLICY_READ_ONLY = 'policy_read_only';
/** Headscale parsed the document and rejected it. The message is a syntax position or a bad reference. */
export const POLICY_REJECTED = 'policy_rejected';
// ── Companion API ─────────────────────────────────────────────────────────────────────────────────
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
// same domain is independent and may still work. Contract: COMMS/HEADSCALE_COMPANION_API.md.
/** Never available for a companion that is missing — the reason says which flavour of missing. */
type Unavailable = { available: false; reason: string };
export type CompanionVerdict = 'ok' | 'degraded' | 'down' | 'unknown';
export type CompanionContainer = {
status: string;
running: boolean;
exitCode: number;
restartCount: number;
startedAt: string;
/** The RFC3339 zero date (`0001-…`) while the container is running. */
finishedAt: string;
/** Null when the image defines no healthcheck. */
healthcheck: string | null;
};
export type CompanionHealthBody = {
verdict: CompanionVerdict;
/** Whether Headscale's own HTTP is answering — the "is the control plane serving?" signal. */
connected: boolean;
container?: CompanionContainer;
/** Human string for the probe outcome, e.g. `GET /health -> 200`, `unreachable`, `container not running`. */
probe?: string;
/** Only on `unknown`: docker has no container by that name. */
reason?: string;
/** Only when not ok — best-effort guesses read out of the logs. May be empty. */
likelyCauses?: string[];
healthcheckOutput?: string | null;
recentLogs?: string[];
};
export type CompanionHealthResult = ({ available: true } & { health: CompanionHealthBody }) | Unavailable;
export type CompanionLogsResult = { available: true; lines: string[] } | Unavailable;
/** The three lifecycle verbs. `stop`/`start` are what the companion calls `disconnect`/`reconnect`. */
export type CompanionAction = 'restart' | 'stop' | 'start';
export type CompanionActionResult =
| { available: true; ok: boolean; action?: string; result?: string; error?: string }
| Unavailable;
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
export type HeadscaleUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };
export type HeadscaleNode = {
id: string;
name: string;
hostname: string;
user: HeadscaleUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** Null means the node's key never expires. */
expiry: string | null;
createdAt: string | null;
registerMethod: string;
tags: string[];
/** What the node advertises. */
availableRoutes: string[];
/** What the admin has approved — the writable set. */
approvedRoutes: string[];
/** What is actually in effect. */
subnetRoutes: string[];
isExitNode: boolean;
};
export type HeadscalePreAuthKey = {
id: string;
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
key: string | null;
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: HeadscaleUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
status: 'active' | 'used' | 'expired';
};
/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
export const NO_ACTIVE_SERVER = 'no_active_server';
// ── Device invites ────────────────────────────────────────────────────────────────────────────────
// An invite is a link the admin sends to whoever needs to join. The pre-auth key is minted when the link is
// claimed, not when it is created, so an unused invite never has a credential attached to it. Contract:
// COMMS/OFFSCALE_INVITE_ENROLLMENT.md; the records live on the server's companion, never in Officer.
export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';
/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
export type HeadscaleInvite = {
id: string;
user: string;
note?: string | null;
status: InviteStatus;
ephemeral?: boolean;
tags?: string[];
createdAt?: string | null;
expiresAt?: string | null;
claimedAt?: string | null;
claimedFromIp?: string | null;
};
/**
* The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
* component state, shown once, and never written to a cache, a query key or a log.
*/
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };
export type InviteCreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note: string;
};
export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;
/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
export const INVITE_TTL_MIN_SECONDS = 60;
export const INVITE_TTL_DEFAULT_SECONDS = 900;
export const INVITE_TTL_MAX_SECONDS = 86_400;
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient, getHeaders } from 'hooks/useClient';
import type { CompanionAction, CompanionActionResult, CompanionHealthResult, CompanionLogsResult } from './shared';
// Client for the active server's Officer Companion. Everything here goes through the headscale sidecar,
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
// decryptable only there. The browser never sees it and never talks to the companion directly.
const BASE = '/offscale/_officer/companion';
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
/**
* The container's health, polled.
*
* Polling is the point rather than a convenience: this section is what you have open while waiting for a
* restart to take, so it has to move on its own. 10s is fast enough to watch a container come back and slow
* enough that a degraded server isn't being hammered while it struggles.
*/
export function useCompanionHealth() {
const { get } = useClient();
return useQuery({
queryKey: HEALTH_KEY,
queryFn: () => get<CompanionHealthResult>(`${BASE}/health`),
refetchInterval: 10_000,
// A verdict from ten seconds ago is stale by definition; never show one on remount without refetching.
staleTime: 0,
});
}
/** A snapshot of the last N lines. The live tail is a separate thing — see useCompanionLogStream. */
export function useCompanionLogs(tail: number, enabled: boolean) {
const { get } = useClient();
return useQuery({
queryKey: ['headscale', 'companion', 'logs', tail],
queryFn: () => get<CompanionLogsResult>(`${BASE}/logs?tail=${tail}`),
enabled,
staleTime: 0,
});
}
/**
* Restart / stop / start the Headscale container.
*
* Every one of these drops every node's control-plane connection, so nothing here retries and nothing here
* fires without the owner having confirmed. The health query is invalidated on settle — including on
* failure, where "did it happen anyway?" is exactly the question.
*/
export function useCompanionAction() {
const { post } = useClient();
const qc = useQueryClient();
return useMutation({
mutationFn: (action: CompanionAction) => post<CompanionActionResult>(`${BASE}/${action}`),
onSettled: () => qc.invalidateQueries({ queryKey: HEALTH_KEY }),
});
}
/** How many lines the viewer keeps. Beyond this the browser, not the server, becomes the bottleneck. */
const MAX_LINES = 5000;
export type LogStream = {
lines: string[];
/** Set when the stream ended badly — the companion's own `event: error` frame, or a dropped connection. */
error: string | null;
/** True between opening the request and the stream ending, however it ends. */
live: boolean;
clear: () => void;
};
/**
* The live log tail, over `fetch()` rather than `EventSource`.
*
* `EventSource` cannot send an `Authorization` header and every hop of this chain needs one — Officer's own
* bearer token to reach the platform, and the Headscale admin key from there on. So the SSE framing is
* parsed by hand: split on blank lines, read `data:` and `event:`. It is a small parser and it only has to
* handle what the companion emits (one line per frame, an `event: error` frame before a fatal close).
*
* `follow` changing off aborts mid-stream; the abort is deliberately not reported as an error, since it is
* the owner switching the toggle rather than anything going wrong.
*/
export function useCompanionLogStream(follow: boolean, tail: number): LogStream {
const [lines, setLines] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState(false);
// Batched through a ref: a busy container emits faster than React can render, and one setState per line
// would spend the whole frame budget on log output.
const pending = useRef<string[]>([]);
useEffect(() => {
if (!follow) return;
const controller = new AbortController();
let flushTimer: ReturnType<typeof setInterval> | null = null;
setError(null);
setLive(true);
const flush = () => {
if (pending.current.length === 0) return;
const batch = pending.current;
pending.current = [];
setLines((prev) => {
const next = prev.concat(batch);
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
});
};
const run = async () => {
try {
const res = await fetch(`/api${BASE}/logs/stream?tail=${tail}`, {
headers: getHeaders(),
signal: controller.signal,
});
if (!res.ok || !res.body) {
setError(`the log stream returned ${res.status}`);
return;
}
flushTimer = setInterval(flush, 200);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Frames are separated by a blank line; anything after the last one is a partial frame and waits.
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
let event = 'message';
const data: string[] = [];
for (const rawLine of frame.split('\n')) {
if (rawLine.startsWith('event:')) event = rawLine.slice(6).trim();
else if (rawLine.startsWith('data:')) data.push(rawLine.slice(5).replace(/^ /, ''));
}
if (data.length === 0) continue;
const text = data.join('\n');
if (event === 'error') setError(text);
else pending.current.push(text);
}
}
} catch (err) {
if ((err as Error | null)?.name !== 'AbortError') setError('the log stream disconnected');
} finally {
if (flushTimer) clearInterval(flushTimer);
flush();
// An aborted stream is already being torn down by the effect that replaced this one; letting it set
// state here would flash "not live" onto a stream that is about to reopen.
if (!controller.signal.aborted) setLive(false);
}
};
void run();
return () => {
controller.abort();
if (flushTimer) clearInterval(flushTimer);
setLive(false);
};
}, [follow, tail]);
return { lines, error, live, clear: () => setLines([]) };
}
+163
View File
@@ -0,0 +1,163 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleNode, HeadscaleUserWithCounts, HeadscalePreAuthKey } from './shared';
// Queries for the domain sections. All three act on whichever server is active, so they live under the
// same ['headscale'] key prefix that switching servers invalidates wholesale (see useHeadscaleServers).
//
// Mutations invalidate broadly rather than patching caches: deleting a user changes node counts, approving
// a route changes subnetRoutes, expiring a key changes nothing else but costs one cheap refetch. The lists
// are small and the correctness is worth more than the round trip.
const NODES_KEY = ['headscale', 'nodes'] as const;
const USERS_KEY = ['headscale', 'users'] as const;
const KEYS_KEY = ['headscale', 'keys'] as const;
const EMPTY_NODES: HeadscaleNode[] = [];
const EMPTY_USERS: HeadscaleUserWithCounts[] = [];
const EMPTY_KEYS: HeadscalePreAuthKey[] = [];
export function useHeadscaleNodes() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
const query = useQuery({
queryKey: NODES_KEY,
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
refetchInterval: 20_000,
staleTime: 10_000,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
onSuccess: invalidate,
});
const setTags = useMutation({
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
onSuccess: invalidate,
});
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
const moveToUser = useMutation({
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
onSuccess: invalidate,
});
// Single-route toggle: the sidecar reads the current approved set and writes it back with one change,
// because Headscale's approve_routes replaces the whole set.
const toggleRoute = useMutation({
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
onSuccess: invalidate,
});
return {
nodes: query.data?.nodes ?? EMPTY_NODES,
isLoading: query.isLoading,
error: query.error,
rename,
setTags,
moveToUser,
toggleRoute,
expire,
remove,
};
}
export function useHeadscaleUsers() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
const query = useQuery({
queryKey: USERS_KEY,
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
post('/offscale/_officer/users', input),
onSuccess: invalidate,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
onSuccess: invalidate,
});
return {
users: query.data?.users ?? EMPTY_USERS,
isLoading: query.isLoading,
error: query.error,
create,
rename,
remove,
};
}
export type CreateKeyInput = {
userId: string;
reusable: boolean;
ephemeral: boolean;
expirationDays: number;
aclTags: string[];
};
export function useHeadscaleKeys() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: KEYS_KEY });
const query = useQuery({
queryKey: KEYS_KEY,
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
staleTime: 30_000,
});
// The response carries the only copy of the secret that will ever exist. It is returned to the caller
// (not merged into the list cache) so the view can show it once and deliberately drop it.
const create = useMutation({
mutationFn: (input: CreateKeyInput) =>
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
onSuccess: invalidate,
});
return {
keys: query.data?.keys ?? EMPTY_KEYS,
isLoading: query.isLoading,
error: query.error,
create,
expire,
remove,
};
}
@@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared';
// Device invites for the active server. Everything goes through the headscale sidecar, which proxies the
// server's own companion — see src/servers/sidecar/headscale/invites.ts for why the records live there and
// not here.
//
// The create result is deliberately NOT merged into the list cache. It is the one response that contains the
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
// drops it. The list is refetched instead, which returns the same invite without its token.
const BASE = '/offscale/_officer/enroll/invites';
const INVITES_KEY = ['headscale', 'invites'] as const;
const EMPTY: InvitesListResult = { available: true, invites: [] };
export function useHeadscaleInvites() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: INVITES_KEY });
const query = useQuery({
queryKey: INVITES_KEY,
queryFn: () => get<InvitesListResult>(BASE),
// A pending invite expires on a clock, so a list left open goes wrong on its own. Cheap: one companion
// call against a table with a handful of rows.
refetchInterval: 30_000,
staleTime: 10_000,
});
const create = useMutation({
mutationFn: async (input: InviteCreateInput): Promise<HeadscaleInviteCreated> => {
const result = await post<InviteCreateResult>(BASE, input);
// An unavailable companion is a 200 here, like every companion route — turn it into a rejection so the
// form shows it where the admin is looking rather than rendering an empty link panel.
if (!result.available) throw new Error(result.reason);
return result.invite;
},
onSuccess: invalidate,
});
const revoke = useMutation({
mutationFn: (id: string) => del(`${BASE}/${encodeURIComponent(id)}`),
onSuccess: invalidate,
});
const result = query.data ?? EMPTY;
return {
invites: result.available ? result.invites : [],
/** Set when this server has no enrolment API — a state to explain, not an error. */
unavailable: result.available ? null : result.reason,
isLoading: query.isLoading,
error: query.error,
create,
revoke,
};
}
@@ -0,0 +1,79 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscalePolicy } from './shared';
import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
// The active server's ACL policy. One document, one query, one mutation — the interesting part is entirely
// in how a failed save is classified, because the two failures need opposite reactions from the owner:
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
const POLICY_KEY = ['headscale', 'policy'] as const;
const PATH = '/offscale/_officer/policy';
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
/** useClient throws `{status, message}` with the raw body text — dig the sidecar's `{error, code}` out. */
export function policySaveFailure(err: unknown): PolicySaveFailure {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return { kind: 'unknown', message: 'The policy could not be saved' };
try {
const parsed = JSON.parse(raw) as { error?: unknown; code?: unknown };
const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The policy could not be saved';
if (parsed.code === POLICY_READ_ONLY) return { kind: 'readOnly', message };
if (parsed.code === POLICY_REJECTED) return { kind: 'rejected', message };
return { kind: 'unknown', message };
} catch {
return { kind: 'unknown', message: raw.slice(0, 300) };
}
}
/** What the assistant proposes. Never saved by the hook — PolicyView puts it in the draft. */
export type PolicyProposal = { explanation: string; policy: string };
/** The assistant's failures are all one sentence to the owner; only the sidecar's `error` field is useful. */
export function assistFailure(err: unknown): string {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return 'The assistant could not be reached';
try {
const parsed = JSON.parse(raw) as { error?: unknown };
return typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The assistant could not be reached';
} catch {
return raw.slice(0, 300);
}
}
/**
* Ask for a revised policy in English. Separate from `useHeadscalePolicy` because it is a different kind of
* thing: no cache, no query key, nothing to invalidate — one request, one answer, discarded on reload.
*/
export function useHeadscalePolicyAssist() {
const { post } = useClient();
return useMutation({
mutationFn: (input: { prompt: string; policy: string }) => post<PolicyProposal>(`${PATH}/assist`, input),
});
}
export function useHeadscalePolicy() {
const { get, put } = useClient();
const qc = useQueryClient();
const query = useQuery({
queryKey: POLICY_KEY,
queryFn: () => get<HeadscalePolicy>(PATH),
// No polling and a long staleTime: this is a document someone is editing. A background refetch that
// replaced the textarea under a half-written rule would be the worst thing this screen could do.
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
const save = useMutation({
mutationFn: (policy: string) => put<HeadscalePolicy>(PATH, { policy }),
// Seed the cache from the response rather than invalidating: a refetch here would race the editor's
// own state and could show the pre-save document for a frame.
onSuccess: (data) => qc.setQueryData(POLICY_KEY, data),
});
return { policy: query.data ?? null, isLoading: query.isLoading, error: query.error, refetch: query.refetch, save };
}
@@ -0,0 +1,12 @@
import { useParams } from 'react-router';
import { DEFAULT_HEADSCALE_SECTION, isHeadscaleSection, type HeadscaleSectionId } from './shared';
// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md:
// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to,
// opened in a new tab, or reached with the back button. HeadscaleScreen redirects anything unrecognised, so the
// fallback here is only for the instant before that lands.
export function useHeadscaleSection(): HeadscaleSectionId {
const { section } = useParams();
return isHeadscaleSection(section) ? section : DEFAULT_HEADSCALE_SECTION;
}
+102
View File
@@ -0,0 +1,102 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } 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 = '/offscale/_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; sshHost?: string };
/** `sshHost: ''` clears the console target; omitting it leaves whatever is stored alone. */
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string; sshHost?: 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,
};
}
/**
* Try to open a shell on a console target with the keys already on this machine. Takes the host rather than a
* server id so the form can test a value before it is saved — which is when a typo is still cheap to fix.
*/
export function useHeadscaleSshTest() {
const { post } = useClient();
return useMutation({
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
});
}
/** 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`),
});
}