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>
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|