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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user