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
@@ -57,7 +57,16 @@ async function readDisks() {
const { stdout } = await exec('df', [
'-B1',
'--output=target,fstype,size,used,pcent',
'-x', 'tmpfs', '-x', 'devtmpfs', '-x', 'squashfs', '-x', 'overlay', '-x', 'efivarfs',
'-x',
'tmpfs',
'-x',
'devtmpfs',
'-x',
'squashfs',
'-x',
'overlay',
'-x',
'efivarfs',
]);
return stdout
.trim()
@@ -131,7 +140,9 @@ async function readTemps() {
}
const CPU_DRIVERS = ['k10temp', 'zenpower', 'coretemp', 'k8temp', 'cpu_thermal'];
const cpu =
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label)) ??
sensors.find(
(s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label),
) ??
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase())) ??
null;
return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors };
@@ -151,15 +162,18 @@ async function readGpu() {
const busyRaw = await readFile(`${dev}/gpu_busy_percent`, 'utf8').catch(() => null);
if (busyRaw == null) continue;
const busyPct = Number.parseInt(busyRaw.trim(), 10);
const vramUsed = Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramTotal = Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramUsed =
Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramTotal =
Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0;
return { busyPct: Number.isFinite(busyPct) ? busyPct : 0, vramUsedBytes: vramUsed, vramTotalBytes: vramTotal };
}
return null;
}
// Network throughput — bytes/sec computed from the delta since the previous /stats call (~2s window).
let lastNet: { total: { rx: number; tx: number }; per: Record<string, { rx: number; tx: number }>; ts: number } | null = null;
let lastNet: { total: { rx: number; tx: number }; per: Record<string, { rx: number; tx: number }>; ts: number } | null =
null;
async function readNet() {
const now = Date.now();
let data: string;
@@ -185,11 +199,20 @@ async function readNet() {
}
const prev = lastNet;
lastNet = { total: { rx: totRx, tx: totTx }, per, ts: now };
if (!prev || now <= prev.ts) return { rxBytesPerSec: 0, txBytesPerSec: 0, interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] };
if (!prev || now <= prev.ts)
return {
rxBytesPerSec: 0,
txBytesPerSec: 0,
interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[],
};
const dt = (now - prev.ts) / 1000;
const rate = (cur: number, old: number) => Math.max(0, Math.round((cur - old) / dt));
const interfaces = Object.entries(per)
.map(([name, v]) => ({ name, rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx), txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx) }))
.map(([name, v]) => ({
name,
rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx),
txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx),
}))
.filter((i) => i.rxBytesPerSec > 0 || i.txBytesPerSec > 0)
.sort((a, b) => b.rxBytesPerSec + b.txBytesPerSec - (a.rxBytesPerSec + a.txBytesPerSec));
return { rxBytesPerSec: rate(totRx, prev.total.rx), txBytesPerSec: rate(totTx, prev.total.tx), interfaces };
@@ -215,7 +238,9 @@ async function readPower() {
for (const d of await readdir('/sys/class/hwmon')) {
const base = `/sys/class/hwmon/${d}`;
if ((await readFile(`${base}/name`, 'utf8').catch(() => '')).trim() !== 'amdgpu') continue;
const p = (await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ?? (await readFile(`${base}/power1_input`, 'utf8').catch(() => null));
const p =
(await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ??
(await readFile(`${base}/power1_input`, 'utf8').catch(() => null));
if (p != null) {
const uw = Number.parseInt(p.trim(), 10);
if (Number.isFinite(uw)) gpuWatts = Math.round((uw / 1e6) * 10) / 10;
@@ -233,8 +258,7 @@ systemMonitorRouter.get('/stats', async (ctx) => {
await new Promise((r) => setTimeout(r, 120));
const second = await readCpuSample().catch(() => null);
const cpuUsage =
first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
const cpuUsage = first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
const perCore =
first && second
? second.perCore.map((c, i) => {
@@ -311,7 +335,9 @@ systemMonitorRouter.get('/pm2', async (ctx) => {
// GET /docker — running docker containers (the "dockers" scope).
systemMonitorRouter.get('/docker', async (ctx) => {
try {
const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], { maxBuffer: 8 * 1024 * 1024 });
const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], {
maxBuffer: 8 * 1024 * 1024,
});
return ctx.json({
containers: stdout
.trim()