Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
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 { useOffscaleServers } from './useOffscaleServers';
|
|
import {
|
|
useCompanionAction,
|
|
useCompanionHealth,
|
|
useCompanionLogStream,
|
|
useCompanionLogs,
|
|
} from './useOffscaleCompanion';
|
|
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 } = useOffscaleServers();
|
|
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>
|
|
);
|
|
};
|