app
Service Containers
Public Unreviewedby John Lauer
One dashboard for every Adom service container: what is healthy, what is down, why, and a restart button that runs the watchdog's own start command. Reads the watchdog's verdicts off disk, so it costs no extra probes and no vendor API quota.
← Commit history
Publish 1.0.0
22 files changed
+1892
SKILL.md+81app/cli.js+300app/fonts/familjen-grotesk-latin-500-normal.woff2app/fonts/familjen-grotesk-latin-600-normal.woff2app/fonts/familjen-grotesk-latin-700-normal.woff2app/fonts/jetbrains-mono-400.woff2app/fonts/jetbrains-mono-500.woff2app/fonts/satoshi-400-normal.woff2app/fonts/satoshi-500-normal.woff2app/fonts/satoshi-700-normal.woff2app/icon.svg+5app/index.html+766app/package.json+7app/server.js+399app/state.js+218dockbar.json+32docs/HERO.md+8docs/hero.pngicon.svg+5install.sh+10package.json+56uninstall.sh+5SKILL.mdadded+81@@ -0,0 +1,81 @@+---+name: svcwatch+description: >+ The Services dashboard for Adom service containers: which are healthy, which are+ down, why, and a restart that runs the watchdog's own start command. Reads the+ watchdog's on-disk verdicts, so it never re-probes a vendor API or spends quota.+ Use when someone asks whether their services are up, which one is down, why a+ service went down, or wants one restarted. Trigger words: are my services up,+ service containers, service dashboard, is mouser down, is digikey down, is jlcpcb+ down, service health, restart a service, why is my service down, svcwatch.+---++# svcwatch++One dashboard for every Adom service container.++## Where the data comes from++svcwatch does **not** probe anything. `service-watchdog.sh` (cron, every 5 minutes)+and `kicad-watchdog-of-watchdogs.sh` already probe every container and write one+verdict per check into `$WD_STATE_DIR` (default `~/.watchdog-state`). svcwatch reads+those files.++That is deliberate. Mouser's `?live=true` burns a search slot exactly like a real+query, so a dashboard that re-probed on every page load would spend the very quota+the service exists to spend on real work.++It also means a stale dashboard is a real signal: if the newest verdict is more than+15 minutes old, the page shows a banner saying the watchdog itself has stopped. That+is the failure mode that once hid a 7.3 hour OAuth Gateway outage.++## Commands++```+svcwatch serve [--print-url] start the server (self-backgrounds, prints the URL)+svcwatch show open or refresh the Services webview tab+svcwatch status one-line health of every service container+svcwatch health is svcwatch itself up+svcwatch ls running instances, with version drift+svcwatch stop shut every instance down+svcwatch ui toast "..." raise a toast on the page+svcwatch ui sweep run a watchdog sweep now+svcwatch ui restart <svc> --reason "..." restart one service+```++`ui restart` refuses without a written `--reason`. Restarting a shared service+interrupts whoever is using it, so the reason is recorded, not decorative.++## Endpoints++| Route | Purpose |+|---|---|+| `GET /state` | Full state: services, per-check verdicts, log tail, counts |+| `GET /console` | The app's own log ring buffer |+| `GET /health` | Liveness plus a count rollup. Sends `Access-Control-Allow-Origin: *` because the dock bar probes it cross-origin |+| `GET /api/messages?since=` | The live channel. Polled, not SSE: the container's ingress proxy buffers `text/event-stream`, so an EventSource through a `/proxy/<port>/` URL never leaves CONNECTING |+| `POST /api/sweep` | Run a watchdog sweep |+| `POST /api/restart` | Restart one service. Requires a `reason` |+| `POST /shutdown` | Stop the server |++## Displaying this app (REQUIRED, read before showing anything)++To SHOW this app, use `svcwatch show` (it opens the proper Hydrogen webview tab+itself). For manual display, FIRST load the `webview` skill (adom-workspace-control)+and follow it.++- **NEVER open this app in VS Code's "Simple Browser"**, which is not a webview in+ the Adom ecosystem: no live channel, no console read-back, no AI drivability.+ Using it is a bug.+- Surfaces: **wv**, the Hydrogen webview panel (`webview` skill); **pup** (`pup`+ skill); the user's **real signed-in browser** (Adom browser extension); and+ **hdbw**, Hydrogen's standalone Tauri browser window (only under Hydrogen).+ Simple Browser is NEVER one of them.++## Adding a service++svcwatch has no service list of its own. It reads the `TARGETS` rows in+`~/bin/service-watchdog.sh`, so adding a row there makes the card appear, with its+public host, port, SSH target and start command already filled in. A service with no+start command in `TARGETS` gets a disabled Restart button and a tooltip saying so,+rather than a button that would fail.
app/cli.jsadded+300@@ -0,0 +1,300 @@+#!/usr/bin/env node+// svcwatch CLI.+//+// Output contract: every response starts with OK: or ERROR:, and every error+// carries at least one Hint: naming the exact next command.+//+// The dock bar runs `svcwatch serve --print-url` as a launch step and waits for+// that process to EXIT before reading its stdout. So serve must self-background:+// it spawns the server detached, waits for the server to answer, prints the+// direct URL, and returns. A serve that stays in the foreground blocks the exec+// for the whole timeout, gets killed, and the dock reports "started, but <url>+// never answered".++import { spawn, execFile } from 'child_process';+import { readFileSync, readdirSync, unlinkSync, existsSync, mkdirSync } from 'fs';+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';++const DIR = dirname(fileURLToPath(import.meta.url));+const SLUG = 'svcwatch';+const VERSION = JSON.parse(readFileSync(join(DIR, 'package.json'), 'utf8')).version;+const INSTANCES = join(process.env.HOME, '.adom', 'instances', SLUG);++// Strip color when stdout is not a TTY.+const TTY = process.stdout.isTTY;+const dim = s => (TTY ? `\x1b[2m${s}\x1b[0m` : s);++const argv = process.argv.slice(2);+const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'help';+function flag(name) { return argv.includes(name); }+function val(name, fallback = null) {+ const i = argv.indexOf(name);+ return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;+}++const AI_THREAD = val('--ai-thread', process.env.ADOM_AI_THREAD || 'dock');++function ok(msg) { console.log('OK: ' + msg); }+function hint(msg) { console.log('Hint: ' + msg); }+function fail(msg, ...hints) {+ console.error('ERROR: ' + msg);+ for (const h of hints) console.error('Hint: ' + h);+ process.exit(1);+}++function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }++function instances() {+ const out = [];+ if (!existsSync(INSTANCES)) return out;+ for (const f of readdirSync(INSTANCES)) {+ if (!f.endsWith('.json')) continue;+ const p = join(INSTANCES, f);+ let e;+ try { e = JSON.parse(readFileSync(p, 'utf8')); } catch { continue; }+ if (!e.pid || !alive(e.pid)) { try { unlinkSync(p); } catch {} continue; }+ out.push(e);+ }+ return out;+}++async function probe(port, ms = 1200) {+ try {+ const c = new AbortController();+ const t = setTimeout(() => c.abort(), ms);+ const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: c.signal });+ clearTimeout(t);+ return r.ok ? await r.json() : null;+ } catch { return null; }+}++async function liveInstance() {+ for (const e of instances()) {+ if (await probe(e.port)) return e;+ }+ return null;+}++// The direct URL, never the :7380/proxy/<port>/ form. The workspace is local+// WSL2 with mirrored networking, so a distro port IS a Windows localhost port.+// 127.0.0.1 rather than the name localhost: IPv6-first resolution costs about+// 800ms per request on Windows.+const directUrl = port => `http://127.0.0.1:${port}/`;++// The proxy URL is what a webview OUTSIDE the container must load. A webview+// cannot reach 127.0.0.1 in here.+function proxyUrl(port) {+ const t = process.env.VSCODE_PROXY_URI;+ return t ? t.replace('{{port}}', String(port)) : directUrl(port);+}++async function waitForPort(port, budgetMs = 30000) {+ const deadline = Date.now() + budgetMs;+ while (Date.now() < deadline) {+ const h = await probe(port);+ if (h) return h;+ await new Promise(r => setTimeout(r, 400));+ }+ return null;+}++async function startDetached() {+ mkdirSync(INSTANCES, { recursive: true });+ const port = val('--port', 'auto');+ const child = spawn(process.execPath, [+ join(DIR, 'server.js'), 'serve', '--ai-thread', AI_THREAD, '--port', port,+ ], { detached: true, stdio: ['ignore', 'ignore', 'ignore'] });+ child.unref();++ // The server picks its own port when asked for auto, so discover it through+ // the registry rather than guessing.+ const deadline = Date.now() + 30000;+ while (Date.now() < deadline) {+ const e = (instances()).find(i => i.pid === child.pid);+ if (e) {+ const h = await waitForPort(e.port, 15000);+ if (h) return e;+ }+ await new Promise(r => setTimeout(r, 300));+ }+ return null;+}++// Once serving, the app takes over the tab the dock pre-opened for it.+function claimDockWebview(port) {+ const name = process.env.ADOM_DOCK_WEBVIEW;+ if (!name) return;+ execFile('adom-cli', [+ 'hydrogen', 'webview', 'open-or-refresh',+ '--name', name, '--url', proxyUrl(port),+ ], () => {});+}++async function main() {+ switch (cmd) {++ case 'serve': {+ // Reuse instead of respawn: a second instance would fight for the same+ // registry entry and leave a zombie behind.+ let inst = await liveInstance();+ if (!inst) inst = await startDetached();+ if (!inst) {+ fail('svcwatch did not come up within 30 seconds.',+ 'Run it in the foreground to see why: node ' + join(DIR, 'server.js') + ' serve --port 8901',+ 'Check the watchdog state directory exists: ls /home/adom/.watchdog-state');+ }+ claimDockWebview(inst.port);+ if (flag('--print-url')) {+ // The dock scrapes the first https?://\S+ on stdout. Keep it on its+ // own line and on stdout, never stderr.+ console.log(directUrl(inst.port));+ return;+ }+ ok(`svcwatch v${VERSION} serving on port ${inst.port}`);+ console.log('URL: ' + directUrl(inst.port));+ console.log('Webview URL: ' + proxyUrl(inst.port));+ return;+ }++ case 'show': {+ let inst = await liveInstance();+ if (!inst) inst = await startDetached();+ if (!inst) fail('Could not start svcwatch.', 'Try: svcwatch serve');+ const name = process.env.ADOM_DOCK_WEBVIEW || 'Service Containers';+ execFile('adom-cli', ['hydrogen', 'webview', 'open-or-refresh',+ '--name', name, '--url', proxyUrl(inst.port),+ '--panel-id', val('--panel-id', '') || 'f11c55d1-1007-4520-9769-d8f3b2c0a333',+ ], (err) => {+ if (err) {+ console.error('ERROR: could not open the webview tab.');+ console.error('Hint: find a pane id with: adom-cli hydrogen workspace tabs');+ process.exit(1);+ }+ ok(`Services tab open on port ${inst.port}`);+ });+ return;+ }++ case 'status': {+ const inst = await liveInstance();+ if (!inst) {+ fail('svcwatch is not running.', 'Start it with: svcwatch serve');+ }+ const h = await probe(inst.port, 4000);+ const c = h.counts;+ ok(`${c.ok}/${c.total} services healthy` ++ (c.down ? `, ${c.down} down` : '') ++ (c.pending ? `, ${c.pending} watching` : ''));+ console.log(dim(`port ${inst.port} thread "${inst.thread}" v${inst.version}` ++ (h.sweepAgeS != null ? ` watchdog wrote ${h.sweepAgeS}s ago` : '')));+ if (c.down) hint('Open the dashboard to see which: svcwatch show');+ return;+ }++ case 'health': {+ const inst = await liveInstance();+ if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');+ ok(`svcwatch v${inst.version} answering on port ${inst.port}`);+ return;+ }++ case 'ls': {+ const list = instances();+ if (!list.length) { ok('no svcwatch instances running'); return; }+ for (const e of list) {+ const idleM = Math.round((Date.now() - new Date(e.lastActivity).getTime()) / 60000);+ const drift = e.version !== VERSION+ ? ` WARN: runs ${e.version}, disk has ${VERSION}` : '';+ console.log(`port ${e.port} thread "${e.thread}" idle ${idleM}m v${e.version}${drift}`);+ }+ ok(`${list.length} instance${list.length === 1 ? '' : 's'}`);+ return;+ }++ case 'stop': {+ const list = instances();+ if (!list.length) { ok('nothing to stop'); return; }+ for (const e of list) {+ try {+ await fetch(`http://127.0.0.1:${e.port}/shutdown`, { method: 'POST' });+ } catch {}+ }+ ok(`stopped ${list.length} instance${list.length === 1 ? '' : 's'}`);+ return;+ }++ // Drive the page the same way a person would, so the AI can walk every+ // interaction and assert the result.+ case 'ui': {+ const inst = await liveInstance();+ if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');+ const sub = argv[1];+ const base = `http://127.0.0.1:${inst.port}`;+ const post = async (path, body) => {+ const r = await fetch(base + path, {+ method: 'POST', headers: { 'content-type': 'application/json' },+ body: JSON.stringify(body),+ });+ return { status: r.status, json: await r.json().catch(() => ({})) };+ };++ if (sub === 'toast') {+ // Drop each --flag AND the value that follows it, or "--kind success"+ // leaves the word "success" stuck on the end of the message.+ const words = [];+ for (let i = 2; i < argv.length; i++) {+ if (argv[i].startsWith('--')) { i++; continue; }+ words.push(argv[i]);+ }+ const message = words.join(' ');+ if (!message) fail('No message given.', 'Try: svcwatch ui toast "sweep finished"');+ await post('/api/toast', { kind: val('--kind', 'info'), message });+ ok(`toast raised: ${message}`);+ return;+ }+ if (sub === 'sweep') {+ const r = await post('/api/sweep', {});+ if (r.status !== 200) fail('Sweep failed.', 'Read the log: tail /tmp/service-watchdog.log');+ ok(`sweep complete: ${r.json.counts.ok}/${r.json.counts.total} healthy`);+ return;+ }+ if (sub === 'restart') {+ const label = argv[2];+ const reason = val('--reason');+ if (!label) fail('No service given.', 'Try: svcwatch ui restart oauth --reason "recovering the gateway"');+ if (!reason) {+ fail('Restarting a shared service needs a written reason.',+ `Try: svcwatch ui restart ${label} --reason "why you are restarting it"`);+ }+ const r = await post('/api/restart', { label, reason });+ if (r.status !== 200) {+ fail(r.json.error || 'Restart failed.',+ r.json.hint || 'List valid names with: svcwatch status');+ }+ ok(`${r.json.service} restart dispatched`);+ return;+ }+ fail(`Unknown ui command: ${sub || '(none)'}`,+ 'Try: svcwatch ui toast "..." | svcwatch ui sweep | svcwatch ui restart <service> --reason "..."');+ return;+ }++ default: {+ console.log(`svcwatch v${VERSION}: the Adom service container dashboard`);+ console.log('');+ console.log(' svcwatch serve [--print-url] start the server (self-backgrounds)');+ console.log(' svcwatch show open or refresh the Services webview tab');+ console.log(' svcwatch status one-line health of every service container');+ console.log(' svcwatch health is svcwatch itself up');+ console.log(' svcwatch ls running instances, with version drift');+ console.log(' svcwatch stop shut every instance down');+ console.log(' svcwatch ui toast "..." raise a toast on the page');+ console.log(' svcwatch ui sweep run a watchdog sweep now');+ console.log(' svcwatch ui restart <svc> --reason "..." restart one service');+ return;+ }+ }+}++main().catch(e => fail(e.message, 'This is a bug in svcwatch. Run with: node cli.js ' + cmd));
app/fonts/familjen-grotesk-latin-500-normal.woff2added⋯ 1 unchanged line ⋯
app/fonts/familjen-grotesk-latin-600-normal.woff2added⋯ 1 unchanged line ⋯
app/fonts/familjen-grotesk-latin-700-normal.woff2added⋯ 1 unchanged line ⋯
app/fonts/jetbrains-mono-400.woff2added⋯ 1 unchanged line ⋯
app/fonts/jetbrains-mono-500.woff2added⋯ 1 unchanged line ⋯
app/fonts/satoshi-400-normal.woff2added⋯ 1 unchanged line ⋯
app/fonts/satoshi-500-normal.woff2added⋯ 1 unchanged line ⋯
app/fonts/satoshi-700-normal.woff2added⋯ 1 unchanged line ⋯
app/icon.svgadded+5@@ -0,0 +1,5 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">+ <rect x="3" y="4" width="18" height="6" rx="2"/>+ <rect x="3" y="14" width="18" height="6" rx="2"/>+ <path d="M7 7h.01M7 17h.01"/>+</svg>
app/index.htmladded+766@@ -0,0 +1,766 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Service Containers</title>+<link rel="icon" href="icon.svg" type="image/svg+xml">+<style>+ /* Brand faces, served from this app rather than adom.inc: the font host+ sends no Access-Control-Allow-Origin, so a cross-origin @font-face from+ the proxy URL is blocked and silently falls back. Same-origin fixes it. */+ @font-face { font-family:'Familjen Grotesk'; src:url('fonts/familjen-grotesk-latin-500-normal.woff2') format('woff2'); font-weight:500; font-display:swap; }+ @font-face { font-family:'Familjen Grotesk'; src:url('fonts/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight:600; font-display:swap; }+ @font-face { font-family:'Familjen Grotesk'; src:url('fonts/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight:700; font-display:swap; }+ @font-face { font-family:'Satoshi'; src:url('fonts/satoshi-400-normal.woff2') format('woff2'); font-weight:400; font-display:swap; }+ @font-face { font-family:'Satoshi'; src:url('fonts/satoshi-500-normal.woff2') format('woff2'); font-weight:500; font-display:swap; }+ @font-face { font-family:'Satoshi'; src:url('fonts/satoshi-700-normal.woff2') format('woff2'); font-weight:700; font-display:swap; }+ @font-face { font-family:'JetBrains Mono'; src:url('fonts/jetbrains-mono-400.woff2') format('woff2'); font-weight:400; font-display:swap; }+ @font-face { font-family:'JetBrains Mono'; src:url('fonts/jetbrains-mono-500.woff2') format('woff2'); font-weight:500; font-display:swap; }++ :root {+ /* Adom core theme tokens. Copied from the design system table, not retyped. */+ --bg:#0d1117;+ --surface:#161b22;+ --elevated:#1c2128;+ --overlay:#21262d;+ --border:#30363d;+ --text:#e6edf3;+ --text-body:#c9d1d9;+ --text-2:#8b949e;+ --text-3:#484f58; /* borders and disabled chrome only, never text */+ --accent:#00b8b1;+ --accent-hover:#00d4cb;+ --accent-bright:#00e6dc;+ --on-accent:#05221f;+ --green:#3fb950;+ --red:#f85149;+ --yellow:#d29922;+ --blue:#64abff;++ --hdr:44px;+ --r-btn:8px;+ --r-card:12px;+ --r-pill:999px;+ --shadow:0 1px 2px rgba(0,0,0,.4), 0 8px 24px -14px rgba(0,0,0,.75);+ }++ * { box-sizing:border-box; }++ /* Scrollbars: barely visible. The content is the subject; the bar is a hint.+ Firefox and the standard: */+ * { scrollbar-width:thin; scrollbar-color:rgba(139,148,158,.38) transparent; }+ /* Chromium and WebKit: */+ ::-webkit-scrollbar { width:10px; height:10px; }+ ::-webkit-scrollbar-track { background:transparent; }+ ::-webkit-scrollbar-thumb {+ background:rgba(139,148,158,.32);+ border-radius:999px; /* a pill, never a rectangle */+ border:2px solid transparent; /* insets the pill off the edge... */+ background-clip:padding-box; /* ...so the border reads as padding */+ }+ ::-webkit-scrollbar-thumb:hover {+ background:rgba(0,184,177,.55); background-clip:padding-box;+ }+ ::-webkit-scrollbar-corner { background:transparent; }+ html, body { height:100%; }+ body {+ margin:0; background:var(--bg); color:var(--text-body);+ font-family:'Satoshi', ui-sans-serif, system-ui, sans-serif;+ font-size:15px; line-height:1.5; -webkit-font-smoothing:antialiased;+ }+ button { font:inherit; color:inherit; cursor:pointer; }+ :focus-visible { outline:2px solid var(--accent-bright); outline-offset:2px; border-radius:var(--r-btn); }+ .mono { font-family:'JetBrains Mono', ui-monospace, monospace; }++ /* ── header: 44px, three zones ─────────────────────────────── */+ header.app-header {+ position:sticky; top:0; z-index:40; height:var(--hdr);+ display:flex; align-items:center; gap:12px; padding:0 12px;+ background:var(--surface); border-bottom:1px solid var(--border);+ }+ .brand {+ display:flex; align-items:center; gap:8px; flex:none;+ font-family:'Familjen Grotesk', sans-serif; font-weight:600;+ color:var(--accent); font-size:.95rem; white-space:nowrap;+ }+ .brand svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.8; flex:none; }+ .brand .app-name { color:var(--accent); }+ .brand .sub { color:var(--text-2); font-weight:500; }+ .vsep { width:1px; height:20px; background:var(--border); flex:none; }++ .subject { display:flex; align-items:center; gap:8px; min-width:0; }+ .led-hdr { width:8px; height:8px; border-radius:50%; background:var(--green); flex:none; }+ .led-hdr.bad { background:var(--red); box-shadow:0 0 0 3px rgba(248,81,73,.18); }+ .led-hdr.warn { background:var(--yellow); box-shadow:0 0 0 3px rgba(210,153,34,.18); }+ .subject .name { font-weight:700; color:var(--text); white-space:nowrap;+ overflow:hidden; text-overflow:ellipsis; }+ .subject .meta { font-family:'JetBrains Mono', monospace; font-size:.75rem;+ color:var(--text-2); white-space:nowrap; font-variant-numeric:tabular-nums; }+ header.app-header { overflow:hidden; }+ /* The header stays ONE row at 44px at every width. As space runs out the+ subject sheds its parts in order of least value: the meta line first,+ then the brand subtitle, then the subject name. The status LED and the+ healthy/down count are the last things to go, because they are the+ reason someone opens this page. */+ @media (max-width:720px) { .brand .sub { display:none; } }+ @media (max-width:600px) {+ .subject .name { display:none; }+ .vsep { display:none; }+ }+ @media (max-width:420px) { .hbtn { padding:6px 10px; font-size:.8rem; } }++ .actions { margin-left:auto; display:flex; align-items:center; gap:8px; }+ .hbtn {+ background:var(--elevated); border:1px solid var(--border); border-radius:var(--r-btn);+ padding:6px 12px; font-size:.85rem; color:var(--text-body); white-space:nowrap;+ display:inline-flex; align-items:center; gap:6px; transition:background .15s ease, border-color .15s ease;+ }+ .hbtn:hover { background:var(--overlay); border-color:var(--text-3); color:var(--text); }+ .hbtn:active { background:var(--surface); }+ .hbtn[disabled] { opacity:.5; cursor:progress; }+ .icon-btn {+ background:none; border:1px solid transparent; border-radius:var(--r-btn);+ padding:6px; line-height:0; transition:background .15s ease;+ }+ .icon-btn:hover { background:var(--elevated); border-color:var(--border); }+ .icon-btn:active { background:var(--overlay); }+ .icon-btn svg { width:20px; height:20px; stroke:var(--text-2); fill:none; stroke-width:1.8; }+ .icon-btn:hover svg { stroke:var(--text); }++ /* Fingers are not cursors. */+ @media (hover:none), (pointer:coarse) {+ .hbtn, .icon-btn { min-height:44px; min-width:44px; justify-content:center; }+ .sbtn { min-height:44px; }+ .chip { min-height:44px; display:inline-flex; align-items:center; }+ }++ /* ── layout ────────────────────────────────────────────────── */+ /* Full bleed: the dashboard fills the window at every width. The card grid+ is auto-fill, so a wider window buys more columns rather than a wider+ empty margin. */+ main { width:100%; padding:16px 20px 64px;+ display:flex; flex-direction:column; gap:16px; }++ .banner {+ display:none; gap:12px; align-items:flex-start;+ background:var(--elevated); border:1px solid var(--yellow); border-radius:var(--r-btn);+ padding:12px 16px; font-size:.9rem; color:var(--text-body);+ }+ .banner.show { display:flex; }+ .banner b { color:var(--yellow); }+ .banner svg { width:18px; height:18px; flex:none; fill:none; stroke:var(--yellow); stroke-width:1.8; margin-top:1px; }++ .filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }+ .chip {+ background:var(--elevated); border:1px solid var(--border); border-radius:var(--r-pill);+ padding:6px 14px; font-size:.85rem; color:var(--text-2); transition:all .15s ease;+ }+ .chip:hover { border-color:var(--text-3); color:var(--text-body); }+ .chip[aria-pressed="true"] {+ background:var(--overlay); border-color:var(--accent); color:var(--accent);+ box-shadow:inset 0 0 0 1px var(--accent);+ }+ .hint { font-family:'JetBrains Mono', monospace; font-size:.75rem; color:var(--text-2); margin-left:auto; }++ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:12px; }++ .card {+ position:relative; background:var(--surface); border:1px solid var(--border);+ border-radius:var(--r-card); box-shadow:var(--shadow); overflow:hidden;+ display:flex; flex-direction:column;+ }+ .card::before { content:""; position:absolute; inset:0 auto 0 0; width:3px; background:var(--green); }+ .card.s-down::before { background:var(--red); }+ .card.s-pending::before { background:var(--yellow); }+ .card.s-unknown::before, .card.s-retired::before { background:var(--text-3); }+ .card.s-retired { opacity:.75; }++ .card-head { padding:16px 16px 12px; display:flex; flex-direction:column; gap:8px; }+ .card-title { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }+ .card-title h3 { margin:0; font-family:'Familjen Grotesk', sans-serif;+ font-size:1.05rem; font-weight:600; color:var(--text); }+ .card-sub { display:block; font-family:'JetBrains Mono', monospace; font-size:.72rem;+ color:var(--text-2); margin-top:3px; }+ .card-note { font-size:.85rem; color:var(--text-body); margin:0; }++ .pill {+ flex:none; font-family:'JetBrains Mono', monospace; font-size:.68rem; font-weight:500;+ letter-spacing:.04em; padding:4px 10px; border-radius:var(--r-pill);+ white-space:nowrap; border:1px solid transparent;+ }+ .pill.ok { background:rgba(63,185,80,.12); color:var(--green); border-color:rgba(63,185,80,.35); }+ .pill.down { background:rgba(248,81,73,.12); color:var(--red); border-color:rgba(248,81,73,.35); }+ .pill.pending { background:rgba(210,153,34,.12); color:var(--yellow); border-color:rgba(210,153,34,.35); }+ .pill.unknown { background:var(--overlay); color:var(--text-2); border-color:var(--border); }++ .checks { border-top:1px solid var(--border); display:flex; flex-direction:column; }+ .check {+ display:flex; align-items:center; gap:10px; padding:8px 16px;+ border-bottom:1px solid var(--border); font-size:.85rem;+ }+ .check:last-child { border-bottom:none; }+ .led { width:8px; height:8px; border-radius:50%; flex:none; background:var(--green); }+ .led.down { background:var(--red); box-shadow:0 0 0 3px rgba(248,81,73,.18); }+ .led.pending { background:var(--yellow); box-shadow:0 0 0 3px rgba(210,153,34,.18); }+ .check-name { color:var(--text); }+ .check-blurb { color:var(--text-2); font-size:.78rem; margin-left:auto; text-align:right; }+ .check-reason { color:var(--red); font-size:.78rem; margin-left:auto; text-align:right; max-width:62%; }++ .card-foot {+ margin-top:auto; /* cards stretch to equal height; pin the footer to the bottom */+ display:flex; align-items:center; gap:12px; flex-wrap:wrap;+ padding:12px 16px; border-top:1px solid var(--border); background:var(--elevated);+ }+ .card-foot a {+ font-family:'JetBrains Mono', monospace; font-size:.74rem; color:var(--blue);+ text-decoration:none; border-bottom:1px solid transparent; word-break:break-all;+ }+ .card-foot a:hover { border-bottom-color:var(--blue); }+ .card-foot .nohost { font-family:'JetBrains Mono', monospace; font-size:.74rem; color:var(--text-2); }+ .sbtn {+ margin-left:auto; background:var(--overlay); border:1px solid var(--border);+ border-radius:var(--r-btn); padding:6px 12px; font-size:.8rem; color:var(--text-body);+ transition:all .15s ease;+ }+ .sbtn:hover:not([disabled]) { border-color:var(--accent); color:var(--accent); }+ .sbtn:active:not([disabled]) { background:var(--surface); }+ .sbtn[disabled] { opacity:.45; cursor:not-allowed; }++ /* ── sections and log ──────────────────────────────────────── */+ section h2 {+ margin:0 0 12px; font-family:'Familjen Grotesk', sans-serif;+ font-size:.88rem; font-weight:600; letter-spacing:.02em; color:var(--text-2);+ border-bottom:1px solid var(--border); padding-bottom:8px;+ display:flex; justify-content:space-between; align-items:baseline; gap:12px;+ }+ h2 .sub { font-family:'JetBrains Mono', monospace; font-size:.72rem;+ color:var(--text-2); letter-spacing:0; font-weight:400; }+ .log {+ background:var(--surface); border:1px solid var(--border); border-radius:var(--r-card);+ max-height:290px; overflow:auto;+ /* Keep the layout from jumping sideways when the bar appears. */+ scrollbar-gutter:stable;+ }+ /* Inset the track by at least the radius, or the bar slices the corner curves. */+ .log::-webkit-scrollbar-track { margin:12px 0; background:transparent; }+ .log-row {+ display:flex; gap:12px; padding:6px 16px; border-bottom:1px solid var(--border);+ font-family:'JetBrains Mono', monospace; font-size:.75rem;+ }+ .log-row:last-child { border-bottom:none; }+ .log-ts { color:var(--text-2); flex:none; font-variant-numeric:tabular-nums; }+ .log-text { color:var(--text-body); word-break:break-word; }+ .log-empty { padding:16px; color:var(--text-2); font-size:.85rem; }+ .empty { color:var(--text-2); font-size:.9rem; grid-column:1/-1; }++ /* ── settings modal: dim 26%, never blurred ────────────────── */+ .backdrop {+ position:fixed; inset:0; z-index:60; background:rgba(0,0,0,.26);+ display:none; align-items:flex-start; justify-content:center;+ padding:72px 16px 32px; overflow:auto;+ }+ .backdrop.show { display:flex; }+ .modal {+ width:min(560px,100%); background:var(--surface); border:1px solid var(--border);+ border-radius:var(--r-card); box-shadow:0 24px 60px -20px rgba(0,0,0,.85);+ }+ .modal-head {+ display:flex; align-items:center; justify-content:space-between;+ padding:16px; border-bottom:1px solid var(--border);+ }+ .modal-head h2 { margin:0; border:none; padding:0; font-size:1.05rem;+ letter-spacing:0; color:var(--text); }+ .setting { position:relative; padding:16px 16px 16px 20px; border-bottom:1px solid var(--border); }+ .setting::before { content:""; position:absolute; inset:16px auto 16px 0; width:3px;+ background:var(--accent); border-radius:0 2px 2px 0; }+ .setting:last-child { border-bottom:none; }+ .setting h3 { margin:0 0 4px; font-family:'Familjen Grotesk', sans-serif;+ font-size:.95rem; font-weight:600; color:var(--text); }+ .setting p { margin:0 0 12px; font-size:.85rem; color:var(--text-body); }+ .row { display:flex; align-items:center; justify-content:space-between; gap:12px; }+ .row .lbl { font-size:.88rem; color:var(--text-body); }+ .row .def { display:block; font-family:'JetBrains Mono', monospace;+ font-size:.72rem; color:var(--text-2); }+ input[type=number] {+ width:88px; background:var(--elevated); border:1px solid var(--border); color:var(--text);+ border-radius:var(--r-btn); padding:8px; font-family:'JetBrains Mono', monospace; font-size:.85rem;+ }+ input[type=number]:hover { border-color:var(--text-3); }+ .toggle {+ width:44px; height:24px; border-radius:var(--r-pill); background:var(--overlay);+ border:1px solid var(--border); position:relative; flex:none; padding:0;+ transition:background .15s ease, border-color .15s ease;+ }+ .toggle::after { content:""; position:absolute; top:2px; left:2px; width:18px; height:18px;+ border-radius:50%; background:var(--text-2);+ transition:transform .15s ease, background .15s ease; }+ .toggle[aria-pressed="true"] {+ background:rgba(0,184,177,.16); border-color:var(--accent);+ box-shadow:inset 0 0 0 1px var(--accent);+ }+ .toggle[aria-pressed="true"]::after { transform:translateX(20px); background:var(--accent); }+ .toggle:hover { border-color:var(--text-3); }++ /* ── toasts: user feedback and the AI's narration channel ──── */+ #toasts {+ position:fixed; left:50%; bottom:24px; transform:translateX(-50%); z-index:80;+ display:flex; flex-direction:column-reverse; gap:8px; align-items:center;+ width:min(520px, calc(100vw - 32px));+ }+ .toast {+ background:var(--overlay); border:1px solid var(--border); border-left:3px solid var(--accent);+ border-radius:var(--r-btn); padding:12px 16px; font-size:.88rem; color:var(--text-body);+ box-shadow:var(--shadow); animation:rise .18s ease-out; width:100%;+ display:flex; align-items:flex-start; gap:10px; cursor:pointer;+ }+ .toast.success { border-left-color:var(--green); }+ .toast.error { border-left-color:var(--red); }+ .toast svg { width:16px; height:16px; flex:none; fill:none; stroke-width:1.8; margin-top:2px; }+ .toast.info svg { stroke:var(--accent); }+ .toast.success svg { stroke:var(--green); }+ .toast.error svg { stroke:var(--red); }+ @keyframes rise { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }++ /* ── tooltip: body-appended, fixed, viewport-clamped ───────── */+ #tt {+ position:fixed; z-index:99999; max-width:280px; pointer-events:none;+ background:var(--overlay); color:var(--text-body); border:1px solid var(--border);+ border-radius:var(--r-btn); padding:8px 12px; font-size:.82rem; line-height:1.45;+ box-shadow:var(--shadow);+ opacity:0; transform:translateY(4px); transition:opacity .18s ease, transform .18s ease;+ }+ #tt.show { opacity:1; transform:none; }+ /* Hover does not exist on touch, so the tooltip never fires there. Controls+ that need explaining carry visible helper text instead. */+ @media (hover:none), (pointer:coarse) { #tt { display:none; } }++ @media (prefers-reduced-motion:reduce) {+ * { animation:none !important; transition:none !important; }+ }+ @media (max-width:560px) {+ .check-blurb { display:none; }+ main { padding:12px 12px 56px; }+ .grid { grid-template-columns:1fr; }+ }+</style>+</head>+<body>++<header class="app-header">+ <span class="brand">+ <svg viewBox="0 0 24 24" aria-hidden="true">+ <rect x="3" y="4" width="18" height="6" rx="2"/>+ <rect x="3" y="14" width="18" height="6" rx="2"/>+ <path d="M7 7h.01M7 17h.01"/>+ </svg>+ <span class="app-name">Service Containers</span><span class="sub"> · health</span>+ </span>+ <span class="vsep"></span>+ <span class="subject">+ <span class="led-hdr" id="global-led" aria-hidden="true"></span>+ <span class="name" id="subject-name">Adom fleet</span>+ <span class="meta" id="rollup">loading</span>+ </span>+ <span class="actions">+ <button class="hbtn" id="sweep"+ data-tooltip="Runs service-watchdog.sh right now instead of waiting for the next 5 minute cycle. Takes about 30 seconds and re-probes every container.">Run sweep</button>+ <button class="icon-btn" id="settings" aria-label="Settings"+ data-tooltip="Refresh rate, idle shutdown, and which sections are shown.">+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>+ </button>+ </span>+</header>+++<main>+ <div class="banner" id="stale-banner">+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg>+ <span><b>The watchdog itself has gone quiet.</b> <span id="stale-detail"></span>+ Everything below is the last verdict it managed to write, not live truth. This is the exact+ failure mode that hid a 7.3-hour OAuth outage: check cron on this container.</span>+ </div>++ <div class="filters">+ <button class="chip" id="filter-all" aria-pressed="true">All services</button>+ <button class="chip" id="filter-faults" aria-pressed="false">Needs attention</button>+ <span class="hint" id="updated">checking</span>+ </div>++ <div class="grid" id="grid"></div>++ <section id="retired-section" hidden>+ <h2>Deprecated services <span class="sub">off on purpose, not a fault</span></h2>+ <div class="grid" id="retired"></div>+ </section>++ <section id="log-section">+ <h2>Watchdog log <span class="sub">newest first</span></h2>+ <div class="log" id="log"></div>+ </section>+</main>++<div class="backdrop" id="settings-modal">+ <div class="modal" role="dialog" aria-modal="true" aria-label="Settings">+ <div class="modal-head">+ <h2>Settings</h2>+ <button class="icon-btn" id="close-settings" aria-label="Close settings">+ <svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>+ </button>+ </div>++ <div class="setting">+ <h3>Refresh rate</h3>+ <p>How often this page pulls fresh verdicts from disk. It never probes a service directly,+ so a fast refresh costs nothing: the watchdog does the real checking every 5 minutes.</p>+ <div class="row">+ <span class="lbl">Seconds between refreshes <span class="def">Default: 15</span></span>+ <input type="number" id="set-refresh" min="5" max="300" step="5">+ </div>+ </div>++ <div class="setting">+ <h3>Idle shutdown</h3>+ <p>This server exits on its own after a stretch with no requests, so forgotten instances+ don't pile up. Any visit resets the clock.</p>+ <div class="row">+ <span class="lbl">Hours before shutdown <span class="def">Default: 24</span></span>+ <input type="number" id="set-ttl" min="1" max="168" step="1">+ </div>+ </div>++ <div class="setting">+ <h3>Deprecated services</h3>+ <p>Containers that are off on purpose. They still have stale watchdog state on disk;+ showing them keeps you from re-fixing something you already retired.</p>+ <div class="row">+ <span class="lbl">Show the deprecated list <span class="def">Default: on</span></span>+ <button class="toggle" id="set-retired" aria-pressed="true" aria-label="Show deprecated services"></button>+ </div>+ </div>++ <div class="setting">+ <h3>Watchdog log</h3>+ <p>The tail of what the watchdogs actually did: failures, diagnoses, and recoveries.</p>+ <div class="row">+ <span class="lbl">Show the log panel <span class="def">Default: on</span></span>+ <button class="toggle" id="set-log" aria-pressed="true" aria-label="Show watchdog log"></button>+ </div>+ </div>+ </div>+</div>++<div id="toasts"></div>++<script>+const $ = id => document.getElementById(id);+// ── tooltips ───────────────────────────────────────────+// A fixed, body-appended div, never a ::after pseudo-element: cards use+// overflow:hidden, which would clip one. Clamped on BOTH axes so it can+// never run off an edge, and flipped rather than clipped near the bottom+// or right.+const TT = document.createElement('div');+TT.id = 'tt';+document.body.appendChild(TT);+let ttTimer = null;++function placeTip(anchorEl) {+ const M = 8, OFF = 14;+ const r = anchorEl.getBoundingClientRect();+ // Measure only AFTER the content is set and laid out.+ const w = TT.offsetWidth, h = TT.offsetHeight;+ let top = r.bottom + OFF;+ if (top + h + M > innerHeight) top = r.top - h - OFF; // flip above+ let left = r.left + r.width / 2 - w / 2;+ if (left + w + M > innerWidth) left = r.right - w; // flip left+ top = Math.max(M, Math.min(top, innerHeight - h - M));+ left = Math.max(M, Math.min(left, innerWidth - w - M));+ TT.style.top = top + 'px';+ TT.style.left = left + 'px';+}++function bindTips(root) {+ for (const el of root.querySelectorAll('[data-tooltip]')) {+ if (el.dataset.tooltipBound) continue;+ el.dataset.tooltipBound = '1';+ el.addEventListener('mouseenter', () => {+ clearTimeout(ttTimer);+ ttTimer = setTimeout(() => {+ TT.textContent = el.dataset.tooltip;+ TT.style.top = '-9999px'; TT.style.left = '-9999px';+ TT.classList.add('show');+ placeTip(el);+ }, 600);+ });+ el.addEventListener('mouseleave', () => {+ clearTimeout(ttTimer);+ TT.classList.remove('show');+ });+ el.addEventListener('click', () => { clearTimeout(ttTimer); TT.classList.remove('show'); });+ }+}++let STATE = null, SETTINGS = null, FILTER = 'all';++function ago(iso) {+ if (!iso) return ',';+ const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));+ if (s < 60) return s + 's ago';+ if (s < 3600) return Math.round(s / 60) + 'm ago';+ if (s < 86400) return Math.round(s / 3600) + 'h ago';+ return Math.round(s / 86400) + 'd ago';+}+function esc(s) {+ return String(s ?? '').replace(/[&<>"']/g, c =>+ ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));+}++const TOAST_ICON = {+ info: '<path d="M12 16v-4M12 8h.01"/><circle cx="12" cy="12" r="9"/>',+ success: '<path d="M20 6 9 17l-5-5"/>',+ error: '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',+};+function toast(kind, message) {+ const el = document.createElement('div');+ el.className = 'toast ' + kind;+ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' ++ (TOAST_ICON[kind] || TOAST_ICON.info) + '</svg><span></span>';+ el.querySelector('span').textContent = message;+ el.title = 'Click to dismiss';+ $('toasts').appendChild(el);+ // Errors stay until dismissed; confirmations clear themselves in 4s and+ // pause while the pointer is over them, so a toast can never vanish+ // mid-read.+ let timer = null;+ const arm = () => { if (kind !== 'error') timer = setTimeout(() => el.remove(), 4000); };+ el.onmouseenter = () => { if (timer) clearTimeout(timer); };+ el.onmouseleave = arm;+ el.onclick = () => el.remove();+ arm();+}++function cardHTML(s, retired) {+ const checks = s.checks.map(c => {+ const bad = c.kind === 'down' || c.kind === 'pending';+ const right = bad && c.reason+ ? '<span class="check-reason">' + esc(c.reason) + '</span>'+ : '<span class="check-blurb">' + esc(c.blurb) + '</span>';+ return '<div class="check"><span class="led ' + esc(c.kind === 'ok' ? '' : c.kind) + '"></span>' ++ '<span class="check-name">' + esc(c.name) + '</span>' + right + '</div>';+ }).join('');++ const canRestart = !retired && s.ssh && s.startCmd;+ const restartBtn = retired ? '' :+ '<button class="sbtn" id="restart-' + esc(s.label) + '" data-label="' + esc(s.label) + '"' ++ (canRestart+ ? ' data-tooltip="Runs this service\'s own start command over SSH, the same one the watchdog uses unattended. Anyone using it will see a few seconds of downtime."'+ : ' disabled data-tooltip="The watchdog config has no start command for this service, so there is nothing safe to run. Add one to TARGETS in service-watchdog.sh."') ++ '>Restart</button>';++ const link = s.host+ ? '<a href="https://' + esc(s.host) + '/health" target="_blank" rel="noopener">' + esc(s.host) + '</a>'+ : '<span class="nohost">no public host</span>';++ return '<article class="card s-' + esc(retired ? 'retired' : s.status) + '" id="svc-' + esc(s.label) + '">' ++ '<div class="card-head"><div class="card-title"><div>' ++ '<h3>' + esc(s.name) + '</h3>' ++ '<span class="card-sub">' + esc(s.repo || s.label) + (s.port ? ' · :' + esc(s.port) : '') + '</span>' ++ '</div><span class="pill ' + esc(retired ? 'unknown' : s.status) + '">' ++ esc(retired ? 'Retired' : s.status === 'pending' ? 'Watching'+ : s.status === 'down' ? 'Down' : s.status === 'unknown' ? 'Unknown' : 'Healthy') + '</span></div>' ++ (retired ? '<p class="card-note">' + esc(s.why) + '</p>'+ : (s.note ? '<p class="card-note">' + esc(s.note) + '</p>' : '')) ++ '</div>' ++ (retired ? '' : '<div class="checks">' + checks + '</div>') ++ '<div class="card-foot">' + link + restartBtn + '</div>' ++ '</article>';+}++function render() {+ if (!STATE) return;+ try { renderInner(); }+ catch (e) {+ // A render crash used to blank the page silently. Say so instead.+ console.error('render failed', e);+ toast('error', 'The page failed to draw: ' + e.message);+ }+}++function renderInner() {+ const c = STATE.counts;+ const faults = c.down + c.pending;++ const led = $('global-led');+ led.className = 'led-hdr' + (c.down ? ' bad' : c.pending ? ' warn' : '');+ $('rollup').textContent = faults === 0+ ? c.ok + '/' + c.total + ' healthy'+ : c.down + ' down, ' + c.pending + ' watching, ' + c.ok + ' healthy';+ $('updated').textContent = 'Watchdog last wrote ' + ago(+ STATE.services.map(s => s.lastCheckedAt).filter(Boolean).sort().pop());++ const stale = STATE.sweepStale;+ $('stale-banner').classList.toggle('show', !!stale);+ if (stale) {+ $('stale-detail').textContent =+ 'Its last verdict is ' + Math.round(STATE.sweepAgeS / 60) + ' minutes old, and it runs every 5.';+ }++ let list = STATE.services;+ if (FILTER === 'faults') list = list.filter(s => s.status !== 'ok');+ $('grid').innerHTML = list.length+ ? list.map(s => cardHTML(s, false)).join('')+ : '<p class="empty">' ++ (FILTER === 'faults' ? 'Nothing needs attention. Every check is green.' : 'No services found.') + '</p>';++ const showRet = SETTINGS?.showRetired !== false && STATE.retired.length;+ $('retired-section').hidden = !showRet;+ if (showRet) $('retired').innerHTML = STATE.retired.map(s => cardHTML(s, true)).join('');++ const showLog = SETTINGS?.showLog !== false;+ $('log-section').hidden = !showLog;+ if (showLog) {+ $('log').innerHTML = STATE.log.length+ ? STATE.log.map(l =>+ '<div class="log-row"><span class="log-ts">' + esc((l.ts || '').replace('T', ' ').slice(0, 19)) ++ '</span><span class="log-text">' + esc(l.text) + '</span></div>').join('')+ : '<div class="log-empty">Nothing logged. These watchdogs only write when something fails or recovers, so an empty log is good news.</div>';+ }++ for (const b of document.querySelectorAll('.sbtn[data-label]')) {+ b.onclick = () => restart(b.dataset.label);+ }+ bindTips(document);+}++async function restart(label) {+ const svc = STATE.services.find(s => s.label === label);+ if (!svc) return;+ if (!confirm('Restart ' + svc.name + '?\n\nAnyone using it right now will see it drop for a few seconds.')) return;+ const btn = $('restart-' + label);+ if (btn) { btn.disabled = true; btn.textContent = 'Restarting…'; }+ try {+ const r = await fetch('api/restart', {+ method: 'POST', headers: { 'content-type': 'application/json' },+ body: JSON.stringify({ label, reason: 'Operator clicked Restart on the services dashboard' }),+ });+ const j = await r.json();+ if (!r.ok) toast('error', j.hint || j.error || 'Restart failed');+ } catch (e) {+ toast('error', 'Restart failed: ' + e.message);+ } finally {+ if (btn) { btn.disabled = false; btn.textContent = 'Restart'; }+ }+}++async function load() {+ const r = await fetch('api/state');+ STATE = await r.json();+ SETTINGS = STATE.settings;+ syncSettingsUI();+ render();+}++function syncSettingsUI() {+ if (!SETTINGS) return;+ $('set-refresh').value = SETTINGS.refreshSeconds;+ $('set-ttl').value = SETTINGS.ttlHours;+ $('set-retired').setAttribute('aria-pressed', String(SETTINGS.showRetired !== false));+ $('set-log').setAttribute('aria-pressed', String(SETTINGS.showLog !== false));+}++async function patchSettings(patch) {+ const r = await fetch('settings', {+ method: 'POST', headers: { 'content-type': 'application/json' },+ body: JSON.stringify(patch),+ });+ SETTINGS = await r.json();+ syncSettingsUI();+ render();+ schedule();+}++// ── events ─────────────────────────────────────────────+$('sweep').onclick = async () => {+ const b = $('sweep');+ b.disabled = true; b.textContent = 'Sweeping…';+ try {+ const r = await fetch('api/sweep', { method: 'POST' });+ if (!r.ok) toast('error', 'Sweep failed');+ } catch (e) { toast('error', 'Sweep failed: ' + e.message); }+ finally { b.disabled = false; b.textContent = 'Run sweep'; }+};++$('settings').onclick = () => $('settings-modal').classList.add('show');+$('close-settings').onclick = () => $('settings-modal').classList.remove('show');+$('settings-modal').onclick = e => { if (e.target === $('settings-modal')) $('settings-modal').classList.remove('show'); };+document.addEventListener('keydown', e => {+ if (e.key === 'Escape') $('settings-modal').classList.remove('show');+});++$('filter-all').onclick = () => setFilter('all');+$('filter-faults').onclick = () => setFilter('faults');+function setFilter(f) {+ FILTER = f;+ $('filter-all').setAttribute('aria-pressed', String(f === 'all'));+ $('filter-faults').setAttribute('aria-pressed', String(f === 'faults'));+ fetch('api/ui', { method: 'POST', headers: { 'content-type': 'application/json' },+ body: JSON.stringify({ filter: f }) }).catch(() => {});+ render();+}++$('set-refresh').onchange = e => patchSettings({ refreshSeconds: Number(e.target.value) });+$('set-ttl').onchange = e => patchSettings({ ttlHours: Number(e.target.value) });+$('set-retired').onclick = e => {+ const on = e.currentTarget.getAttribute('aria-pressed') !== 'true';+ patchSettings({ showRetired: on });+};+$('set-log').onclick = e => {+ const on = e.currentTarget.getAttribute('aria-pressed') !== 'true';+ patchSettings({ showLog: on });+};++// ── live channel ───────────────────────────────────────+// Polling, not SSE: the container's ingress proxy buffers text/event-stream,+// so an EventSource through a /proxy/<port>/ URL never opens. Polling reads+// files the watchdog already wrote, so a fast cadence costs nothing.+let msgSince = 0;+let polling = false;++async function poll() {+ if (polling) return;+ polling = true;+ try {+ const [stateRes, msgRes] = await Promise.all([+ fetch('api/state'),+ fetch('api/messages?since=' + msgSince),+ ]);+ STATE = await stateRes.json();+ SETTINGS = STATE.settings;+ const m = await msgRes.json();+ // First poll only syncs the cursor: don't replay toasts from before the+ // page was open.+ if (msgSince === 0) msgSince = m.seq;+ else for (const item of m.messages) {+ msgSince = Math.max(msgSince, item.seq);+ if (item.event === 'toast') toast(item.data.kind, item.data.message);+ if (item.event === 'settings') SETTINGS = item.data;+ }+ syncSettingsUI();+ render();+ } catch (e) {+ // A failed poll is not worth a toast on every cycle; the "last wrote"+ // stamp in the header going stale is the honest signal.+ } finally { polling = false; }+}++let timer = null;+function schedule() {+ if (timer) clearInterval(timer);+ const ms = Math.max(5, SETTINGS?.refreshSeconds || 15) * 1000;+ timer = setInterval(poll, ms);+}++(async () => { await poll(); schedule(); })();+</script>+</body>+</html>
app/package.jsonadded+7@@ -0,0 +1,7 @@+{+ "name": "svcwatch",+ "version": "1.0.0",+ "private": true,+ "type": "module",+ "scripts": { "start": "node server.js" }+}
app/server.jsadded+399@@ -0,0 +1,399 @@+// svcwatch: the service container dashboard.+//+// Reads the watchdog's verdicts off disk and renders them. It does not probe+// anything itself; see state.js for why that matters (vendor quota).++import { createServer } from 'http';+import { readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } from 'fs';+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';+import { execFile } from 'child_process';++import { readState } from './state.js';++const DIR = dirname(fileURLToPath(import.meta.url));+const SLUG = 'svcwatch';+const VERSION = JSON.parse(readFileSync(join(DIR, 'package.json'), 'utf8')).version;++const INSTANCES = join(process.env.HOME, '.adom', 'instances', SLUG);+const SNAPSHOTS = join(INSTANCES, 'snapshots');+const SETTINGS_FILE = join(process.env.HOME, '.adom', `${SLUG}-settings.json`);++const DEFAULT_SETTINGS = {+ refreshSeconds: 15,+ ttlHours: 24,+ showRetired: true,+ showLog: true,+};++// ── args ───────────────────────────────────────────────────────────────+function argVal(flag, fallback = null) {+ const i = process.argv.indexOf(flag);+ return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;+}+const CMD = process.argv[2] && !process.argv[2].startsWith('-') ? process.argv[2] : 'serve';+const AI_THREAD = argVal('--ai-thread', process.env.ADOM_AI_THREAD || '');+const KEEP_ALIVE = process.argv.includes('--keep-alive');+const TTL_H = Number(argVal('--ttl', process.env.APP_TTL_HOURS || 0)) || null;++const threadSlug = (AI_THREAD || 'default')+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'default';++for (const d of [INSTANCES, SNAPSHOTS, dirname(SETTINGS_FILE)]) mkdirSync(d, { recursive: true });++// ── settings ───────────────────────────────────────────────────────────+function loadSettings() {+ try { return { ...DEFAULT_SETTINGS, ...JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) }; }+ catch { return { ...DEFAULT_SETTINGS }; }+}+function saveSettings(s) {+ const merged = { ...loadSettings(), ...s };+ writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2));+ return merged;+}++// ── registry (Rule 3) ──────────────────────────────────────────────────+const regPath = join(INSTANCES, `${threadSlug}.json`);++function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }++function listInstances() {+ const out = [];+ let files = [];+ try { files = readdirSync(INSTANCES); } catch { return out; }+ for (const f of files) {+ if (!f.endsWith('.json')) continue;+ const p = join(INSTANCES, f);+ let e;+ try { e = JSON.parse(readFileSync(p, 'utf8')); } catch { continue; }+ if (!e.pid || !alive(e.pid)) { try { unlinkSync(p); } catch {} continue; }+ out.push({ ...e, file: p });+ }+ return out;+}++function register(port) {+ writeFileSync(regPath, JSON.stringify({+ pid: process.pid, port, thread: AI_THREAD || '(unnamed)', threadSlug,+ task: SLUG, version: VERSION,+ startedAt: new Date().toISOString(), lastActivity: new Date().toISOString(),+ }, null, 2));+}+function touchRegistry(port) {+ try {+ const e = JSON.parse(readFileSync(regPath, 'utf8'));+ e.lastActivity = new Date().toISOString();+ writeFileSync(regPath, JSON.stringify(e, null, 2));+ } catch { register(port); }+}+function deregister() { try { unlinkSync(regPath); } catch {} }++// ── snapshot (Rule 2): per thread, never shared ────────────────────────+const snapPath = join(SNAPSHOTS, `${threadSlug}.json`);+let uiState = { selected: null, filter: 'all' };+function saveSnapshot() {+ try { writeFileSync(snapPath, JSON.stringify({ uiState, savedAt: new Date().toISOString() }, null, 2)); }+ catch {}+}+function restoreSnapshot() {+ try {+ const s = JSON.parse(readFileSync(snapPath, 'utf8'));+ if (s.uiState) { uiState = s.uiState; return true; }+ } catch {}+ return false;+}++// ── console ring buffer, so the AI can read back what the page did ─────+const consoleLog = [];+function note(level, msg) {+ consoleLog.push({ ts: new Date().toISOString(), level, msg });+ if (consoleLog.length > 300) consoleLog.shift();+}++// ── live channel ───────────────────────────────────────────────────────+// The container's ingress proxy buffers text/event-stream, so an EventSource+// opened through a /proxy/<port>/ URL never leaves CONNECTING. Verified in+// pup, 2026-08-28. So the page POLLS, and anything the server wants to push+// (a CLI-driven toast, a state swap) lands in this queue for the poller to+// drain. /events still works for a direct 127.0.0.1 client.+const clients = new Set();+let msgSeq = 0;+const messages = [];+function broadcast(event, data) {+ messages.push({ seq: ++msgSeq, event, data, ts: Date.now() });+ // Bound the queue and drop anything older than 2 minutes: a page that has+ // been closed all day must not flood the next viewer with stale toasts.+ const cutoff = Date.now() - 120000;+ while (messages.length > 200 || (messages.length && messages[0].ts < cutoff)) messages.shift();+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;+ for (const res of clients) { try { res.write(payload); } catch {} }+}++// ── restart a service, via the watchdog's own start command ────────────+// Reuses the exact start_cmd from service-watchdog.sh's TARGETS so the+// button can never drift from what the watchdog would do unattended.+function restartService(label, cb) {+ const st = readState();+ const svc = st.services.find(s => s.label === label);+ if (!svc) return cb(new Error(`unknown service: ${label}`));+ if (!svc.ssh || !svc.startCmd) {+ return cb(new Error(`${svc.name} has no ssh target or start command in the watchdog config`));+ }+ const args = [+ '-k', '10', '90', 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',+ '-o', 'ServerAliveInterval=5', '-o', 'StrictHostKeyChecking=accept-new',+ svc.ssh,+ `setsid -f nohup bash -c ${JSON.stringify(svc.startCmd)} >> /tmp/service-${label}.log 2>&1 < /dev/null`,+ ];+ note('info', `restart ${svc.name}: ssh ${svc.ssh}`);+ execFile('timeout', args, { timeout: 100000 }, (err, stdout, stderr) => {+ if (err) { note('error', `restart ${svc.name} failed: ${stderr || err.message}`); return cb(err); }+ note('info', `restart ${svc.name}: start command dispatched`);+ cb(null, { ok: true, service: svc.name, dispatched: svc.startCmd });+ });+}++// ── run one watchdog sweep on demand ───────────────────────────────────+function runSweep(cb) {+ note('info', 'running service-watchdog sweep');+ execFile('timeout', ['-k', '30', '270', '/home/adom/bin/service-watchdog.sh'],+ { timeout: 280000 }, (err) => {+ if (err && err.killed) { note('error', 'sweep timed out'); return cb(new Error('sweep timed out')); }+ note('info', 'sweep complete');+ cb(null, { ok: true });+ });+}++// ── HTTP ───────────────────────────────────────────────────────────────+function send(res, code, body, type = 'application/json') {+ res.writeHead(code, {+ 'Content-Type': type,+ 'Cache-Control': 'no-store',+ // The dock bar probes /health cross-origin. Without this it shows a red+ // LED and "not answering" while curl against the same URL succeeds.+ 'Access-Control-Allow-Origin': '*',+ });+ res.end(typeof body === 'string' ? body : JSON.stringify(body, null, 2));+}++let lastActivity = Date.now();+let PORT = 0;++function handler(req, res) {+ lastActivity = Date.now();+ const url = new URL(req.url, `http://127.0.0.1:${PORT}`);+ const p = url.pathname;++ if (p === '/health') {+ const st = readState();+ return send(res, 200, {+ ok: true, service: SLUG, version: VERSION, port: PORT,+ thread: AI_THREAD || null, counts: st.counts, sweepAgeS: st.sweepAgeS,+ });+ }++ if (p === '/state' || p === '/api/state') {+ const st = readState();+ return send(res, 200, { ...st, settings: loadSettings(), uiState, version: VERSION, port: PORT });+ }++ if (p === '/console') return send(res, 200, { entries: consoleLog.slice(-200) });++ if (p === '/api/messages') {+ const since = Number(url.searchParams.get('since') || 0);+ return send(res, 200, { seq: msgSeq, messages: messages.filter(m => m.seq > since) });+ }++ if (p === '/settings' && req.method === 'GET') return send(res, 200, loadSettings());++ if (p === '/settings' && req.method === 'POST') {+ let body = '';+ req.on('data', c => body += c);+ req.on('end', () => {+ let patch = {};+ try { patch = JSON.parse(body || '{}'); } catch { return send(res, 400, { error: 'invalid JSON' }); }+ const merged = saveSettings(patch);+ note('info', `settings updated: ${Object.keys(patch).join(', ')}`);+ broadcast('settings', merged);+ send(res, 200, merged);+ });+ return;+ }++ if (p === '/api/ui' && req.method === 'POST') {+ let body = '';+ req.on('data', c => body += c);+ req.on('end', () => {+ try { uiState = { ...uiState, ...JSON.parse(body || '{}') }; } catch {}+ saveSnapshot();+ send(res, 200, uiState);+ });+ return;+ }++ // AI-drivable: raise a toast on the page from the CLI.+ if (p === '/api/toast' && req.method === 'POST') {+ let body = '';+ req.on('data', c => body += c);+ req.on('end', () => {+ let m = {};+ try { m = JSON.parse(body || '{}'); } catch {}+ const t = { kind: m.kind || 'info', message: m.message || '' };+ note('info', `toast(${t.kind}): ${t.message}`);+ broadcast('toast', t);+ send(res, 200, { ok: true, ...t });+ });+ return;+ }++ if (p === '/api/restart' && req.method === 'POST') {+ let body = '';+ req.on('data', c => body += c);+ req.on('end', () => {+ let m = {};+ try { m = JSON.parse(body || '{}'); } catch {}+ // Restarting someone else's service is out of the norm: demand a reason.+ if (!m.reason || !String(m.reason).trim()) {+ return send(res, 400, {+ error: 'reason_required',+ hint: 'Restarting a shared service interrupts anyone using it. Re-send with a written "reason" saying why.',+ });+ }+ if (!m.label) return send(res, 400, { error: 'label required' });+ broadcast('toast', { kind: 'info', message: `Restarting ${m.label}...` });+ restartService(m.label, (err, out) => {+ if (err) {+ broadcast('toast', { kind: 'error', message: `Restart failed: ${err.message}` });+ return send(res, 500, { error: err.message });+ }+ broadcast('toast', { kind: 'success', message: `${out.service} restart dispatched` });+ broadcast('state', readState());+ send(res, 200, out);+ });+ });+ return;+ }++ if (p === '/api/sweep' && req.method === 'POST') {+ broadcast('toast', { kind: 'info', message: 'Running watchdog sweep...' });+ runSweep((err) => {+ if (err) {+ broadcast('toast', { kind: 'error', message: err.message });+ return send(res, 500, { error: err.message });+ }+ const st = readState();+ broadcast('state', st);+ broadcast('toast', { kind: 'success', message: `Sweep complete: ${st.counts.ok}/${st.counts.total} healthy` });+ send(res, 200, { ok: true, counts: st.counts });+ });+ return;+ }++ if (p === '/events') {+ res.writeHead(200, {+ 'Content-Type': 'text/event-stream',+ 'Cache-Control': 'no-cache',+ 'Connection': 'keep-alive',+ // The container's ingress proxy buffers responses by default, which+ // leaves EventSource stuck in CONNECTING forever. This header opts out,+ // and the padding below forces a flush through any proxy that ignores it.+ 'X-Accel-Buffering': 'no',+ });+ res.write(':' + ' '.repeat(2048) + '\n\n');+ res.write(': connected\n\n');+ if (res.flush) res.flush();+ clients.add(res);+ // Keep-alive comment: an idle SSE stream can be dropped by the proxy.+ const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20000);+ req.on('close', () => { clearInterval(ping); clients.delete(res); });+ return;+ }++ if (p === '/shutdown' && req.method === 'POST') {+ send(res, 200, { ok: true, message: 'shutting down' });+ setTimeout(() => { saveSnapshot(); deregister(); process.exit(0); }, 100);+ return;+ }++ if (p.startsWith('/fonts/') && p.endsWith('.woff2')) {+ // Basename only: never let a path segment escape the fonts directory.+ const name = p.split('/').pop();+ const file = join(DIR, 'fonts', name);+ if (!existsSync(file)) return send(res, 404, { error: 'no such font' });+ res.writeHead(200, { 'Content-Type': 'font/woff2', 'Cache-Control': 'max-age=604800' });+ return res.end(readFileSync(file));+ }++ if (p === '/icon.svg') {+ return send(res, 200, readFileSync(join(DIR, 'icon.svg'), 'utf8'), 'image/svg+xml');+ }++ if (p === '/' || p === '/index.html') {+ return send(res, 200, readFileSync(join(DIR, 'index.html'), 'utf8'), 'text/html; charset=utf-8');+ }++ send(res, 404, { error: 'not found', path: p });+}++// ── boot ───────────────────────────────────────────────────────────────+if (CMD === 'ls') {+ const inst = listInstances();+ if (!inst.length) { console.log('OK: no svcwatch instances running'); process.exit(0); }+ for (const e of inst) {+ const idleM = Math.round((Date.now() - new Date(e.lastActivity).getTime()) / 60000);+ const drift = e.version !== VERSION ? ` WARN: runs ${e.version}, disk has ${VERSION}` : '';+ console.log(`port ${e.port} thread="${e.thread}" idle ${idleM}m v${e.version}${drift}`);+ }+ process.exit(0);+}++// Reuse instead of respawn (Rule 3).+const mine = listInstances().find(e => e.threadSlug === threadSlug);+if (mine && CMD === 'serve') {+ const ageM = Math.round((Date.now() - new Date(mine.startedAt).getTime()) / 60000);+ console.log(`OK: already running on port ${mine.port} (started ${ageM}m ago). Use that instance.`);+ console.log(`URL: http://127.0.0.1:${mine.port}/`);+ process.exit(0);+}++const wantPort = argVal('--port', 'auto');+const server = createServer(handler);+server.listen(wantPort === 'auto' ? 0 : Number(wantPort), '0.0.0.0', () => {+ PORT = server.address().port;+ const restored = restoreSnapshot();+ register(PORT);+ const settings = loadSettings();+ const ttl = TTL_H || settings.ttlHours || 24;++ console.log(`OK: svcwatch v${VERSION} listening on port ${PORT}`);+ console.log(`URL: http://127.0.0.1:${PORT}/`);+ if (restored) console.log(`Restored UI snapshot for thread "${AI_THREAD || 'default'}"`);+ if (KEEP_ALIVE) console.log('Idle reaper disabled (--keep-alive)');+ else console.log(`Idle reaper armed: exits after ${ttl}h with no requests`);++ note('info', `svcwatch v${VERSION} started on ${PORT}`);++ setInterval(() => { touchRegistry(PORT); }, 60e3).unref();++ // Push state on a timer so the page is live without polling.+ setInterval(() => {+ if (clients.size) broadcast('state', readState());+ }, Math.max(5, loadSettings().refreshSeconds) * 1000).unref();++ if (!KEEP_ALIVE) {+ setInterval(() => {+ if (Date.now() - lastActivity > ttl * 3600e3) {+ note('info', 'idle TTL reached, reaping self');+ saveSnapshot();+ deregister();+ execFile('adom-cli', ['hydrogen', 'workspace', 'remove-tab', '--name', 'Services'],+ () => process.exit(0));+ setTimeout(() => process.exit(0), 5000);+ }+ }, 60e3).unref();+ }+});++for (const sig of ['SIGINT', 'SIGTERM']) {+ process.on(sig, () => { saveSnapshot(); deregister(); process.exit(0); });+}
app/state.jsadded+218@@ -0,0 +1,218 @@+// Reads the watchdog's own on-disk state. This app NEVER probes a service+// itself: service-watchdog.sh already does that every 5 minutes and writes+// its verdicts to $WD_STATE_DIR. Probing again here would double the load+// and, for Mouser and DigiKey, spend billable vendor quota just to draw a+// dot green. Read the verdict, don't re-derive it.++import { readFileSync, readdirSync, statSync, existsSync } from 'fs';+import { join } from 'path';++export const STATE_DIR = process.env.WD_STATE_DIR || '/home/adom/.watchdog-state';+const BIN = '/home/adom/bin';++const WATCHDOG_LOGS = [+ '/tmp/service-watchdog.log',+ '/tmp/kicad-watchdog.log',+ '/tmp/parts-vendor-watchdog.log',+];++// Human copy for each check key. The dashboard says what a failure MEANS,+// not just which probe tripped , "auto-deploy is dead" beats "git-auth down".+const CHECK_META = {+ 'health': { name: 'Health', blurb: 'Public /health answers 200' },+ 'deep': { name: 'Deep probe', blurb: 'A real API call, not just the port' },+ 'cron': { name: 'Cron', blurb: 'Scheduled jobs can still run on the container' },+ 'git-auth': { name: 'Git auth', blurb: 'Auto-deploy from main still works' },+ '5xx-rate': { name: '5xx rate', blurb: 'Recent server errors in the log' },+ '5xx': { name: '5xx rate', blurb: 'Recent server errors in the log' },+ 'quota': { name: 'Vendor quota',blurb: 'Daily API allowance not exhausted' },+ 'freshness': { name: 'Data age', blurb: 'Backing dataset is being refreshed' },+ 'credential-health': { name: 'Credentials', blurb: 'Stored credentials still valid' },+ 'deploy': { name: 'Deploy', blurb: 'Latest main is actually running' },+ 'git': { name: 'Git auth', blurb: 'Auto-deploy from main still works' },+};++// Service identity. Parsed from the watchdog TARGETS rows where possible so+// this can't drift; the map below only supplies display names and the notes+// that aren't encoded in the watchdog config.+const DISPLAY = {+ 'mouser': { name: 'Mouser', repo: 'john/service-mouser', note: 'Parts search vendor' },+ 'digikey': { name: 'DigiKey', repo: 'john/service-digikey', note: 'Parts search vendor' },+ 'jlcpcb': { name: 'JLCPCB', repo: 'john/service-jlcpcb', note: 'Parts search vendor, local SQLite' },+ 'tts': { name: 'TTS', repo: 'john/service-tts', note: 'Speech for hands-free' },+ 'step2glb': { name: 'STEP to GLB', repo: 'john/service-step2glb', note: 'Chip thumbnails and 3D' },+ 'oauth': { name: 'OAuth Gateway', repo: 'john/service-oauth', note: 'Static callback relay' },+ 'kicad-v10-default': { name: 'KiCad CLI', repo: 'john/service-kicad', note: 'KiCad 10 headless' },+};++// Some watchdogs write under their own label but describe a facet of an+// existing service. Fold them in rather than inventing a second card.+const MERGE_INTO = { 'kicad-cred': 'kicad-v10-default' };++// Labels whose containers are deprecated. Their state files linger, but+// showing them as failing services would be a lie , they are off on purpose.+const RETIRED = {+ 'wiki': 'Old wiki container, deprecated. wiki.adom.inc is separate and owned elsewhere.',+ 'wiki-adom-inc': 'Monitored elsewhere, not ours to run.',+};++// Display names for retired labels, which have no DISPLAY entry.+const RETIRED_NAMES = {+ 'wiki': 'Old wiki',+ 'wiki-adom-inc': 'wiki.adom.inc',+};++function readTargets() {+ const out = {};+ try {+ const src = readFileSync(join(BIN, 'service-watchdog.sh'), 'utf8');+ const block = src.split('TARGETS=(')[1]?.split('\n)')[0] || '';+ for (const line of block.split('\n')) {+ const m = line.match(/^\s*"([^"]+)"/);+ if (!m) continue;+ const f = m[1].split('|');+ out[f[0]] = { ssh: f[1] || '', host: f[2] || '', port: f[3] || '', start: f[5] || '' };+ }+ } catch {}+ try {+ const src = readFileSync(join(BIN, 'kicad-watchdog-of-watchdogs.sh'), 'utf8');+ const block = src.split('TARGETS=(')[1]?.split('\n)')[0] || '';+ for (const line of block.split('\n')) {+ const m = line.match(/^\s*"([^"]+)"/);+ if (!m) continue;+ const f = m[1].split(':');+ out['kicad-' + f[0]] = { ssh: f[1] || '', host: f[2] || '', port: '8780', start: '' };+ }+ } catch {}+ return out;+}++// A state file holds either "ok" or "<kind>|<reason>|<since>".+function parseStateFile(path) {+ let raw = '';+ try { raw = readFileSync(path, 'utf8').trim(); } catch { return null; }+ if (!raw) return null;+ const [kind, reason = '', since = ''] = raw.split('|');+ let mtime = null;+ try { mtime = statSync(path).mtime.toISOString(); } catch {}+ return { kind, reason, since, mtime };+}++// Roll the per-check verdicts up to one service verdict.+// down - a confirmed fault, already announced+// pending - failing but inside the debounce window, not yet announced+// ok - everything green+function rollup(checks) {+ if (checks.some(c => c.kind === 'down')) return 'down';+ if (checks.some(c => c.kind === 'pending')) return 'pending';+ return checks.length ? 'ok' : 'unknown';+}++export function readLogTail(limit = 60) {+ const lines = [];+ for (const p of WATCHDOG_LOGS) {+ if (!existsSync(p)) continue;+ let txt = '';+ try { txt = readFileSync(p, 'utf8'); } catch { continue; }+ const src = p.split('/').pop().replace('.log', '');+ for (const l of txt.trim().split('\n').slice(-limit)) {+ if (!l.trim()) continue;+ const m = l.match(/^\[([^\]]+)\]\s*(.*)$/);+ lines.push({ ts: m ? m[1] : '', text: m ? m[2] : l, src });+ }+ }+ // Newest first, and only entries that actually carry a timestamp sort well.+ lines.sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));+ return lines.slice(0, limit);+}++export function readState() {+ const targets = readTargets();+ const byLabel = {};++ let files = [];+ try { files = readdirSync(STATE_DIR); } catch {}++ for (const f of files) {+ if (!f.endsWith('.state')) continue;+ const stem = f.slice(0, -'.state'.length);+ // Split on the LAST hyphen group that names a known check, so labels+ // containing hyphens (kicad-v10-default) survive intact.+ let label = stem, check = '';+ const known = Object.keys(CHECK_META).sort((a, b) => b.length - a.length);+ for (const k of known) {+ if (stem.endsWith('-' + k)) { check = k; label = stem.slice(0, -(k.length + 1)); break; }+ }+ if (!check) continue;++ const st = parseStateFile(join(STATE_DIR, f));+ if (!st) continue;++ if (MERGE_INTO[label]) { check = 'cred-' + check; label = MERGE_INTO[label]; }++ byLabel[label] ||= { label, checks: [] };+ const base = check.startsWith('cred-') ? check.slice(5) : check;+ const meta = check.startsWith('cred-')+ ? { name: 'Credential ' + (CHECK_META[base]?.name || base).toLowerCase(),+ blurb: 'Stored credentials still valid' }+ : (CHECK_META[check] || { name: check, blurb: '' });+ byLabel[label].checks.push({+ key: check, name: meta.name, blurb: meta.blurb,+ kind: st.kind, reason: st.reason, since: st.since, checkedAt: st.mtime,+ });+ }++ const services = [];+ const retired = [];++ for (const label of Object.keys(byLabel)) {+ const entry = byLabel[label];+ const t = targets[label] || {};+ const d = DISPLAY[label] || {};+ entry.checks.sort((a, b) => a.name.localeCompare(b.name));++ const row = {+ label,+ name: d.name || label,+ repo: d.repo || '',+ note: d.note || '',+ host: t.host || '',+ port: t.port || '',+ ssh: t.ssh || '',+ startCmd: t.start || '',+ checks: entry.checks,+ status: rollup(entry.checks),+ lastCheckedAt: entry.checks.map(c => c.checkedAt).filter(Boolean).sort().pop() || null,+ };++ if (RETIRED[label]) {+ retired.push({ ...row, name: RETIRED_NAMES[label] || row.name, why: RETIRED[label] });+ continue;+ }+ services.push(row);+ }++ // Faults first, then alphabetical, so what needs attention is at the top.+ const rank = { down: 0, pending: 1, unknown: 2, ok: 3 };+ services.sort((a, b) => (rank[a.status] - rank[b.status]) || a.name.localeCompare(b.name));++ const counts = { total: services.length, ok: 0, pending: 0, down: 0, unknown: 0 };+ for (const s of services) counts[s.status]++;++ // How long since the watchdog last wrote ANY verdict. If this grows past+ // ~10 minutes the watchdog itself has stopped, which is the failure mode+ // that hid the 7.3h OAuth outage , so the dashboard has to show it.+ let sweepAgeS = null;+ const newest = services.map(s => s.lastCheckedAt).filter(Boolean).sort().pop();+ if (newest) sweepAgeS = Math.max(0, Math.round((Date.now() - new Date(newest).getTime()) / 1000));++ return {+ generatedAt: new Date().toISOString(),+ counts,+ sweepAgeS,+ sweepStale: sweepAgeS != null && sweepAgeS > 900,+ services,+ retired,+ log: readLogTail(40),+ };+}
dockbar.jsonadded+32@@ -0,0 +1,32 @@+{+ "dock_manifest": "1.2.0",+ "webview": {+ "title": "Service Containers",+ "favicon": "icon.svg"+ },+ "launch": {+ "mode": "container_command",+ "console": "show",+ "steps": [+ {+ "label": "Installing svcwatch",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install john/svcwatch'",+ "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v svcwatch'",+ "timeout_seconds": 180+ },+ {+ "label": "Starting the services dashboard",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec svcwatch serve --print-url'",+ "await": "url",+ "timeout_seconds": 60+ }+ ],+ "on_ready": "navigate_placeholder",+ "ai": {+ "prompt": "I just opened Service Containers from the dock bar. Give me a one-line summary of my service containers, and if any are down, say which and what you would do about it. Read the state with `svcwatch status`; do not restart anything without asking me first.",+ "thread": "existing",+ "thread_fallback": "new",+ "submit": "staged"+ }+ }+}
docs/HERO.mdadded+8@@ -0,0 +1,8 @@+Generate `hero.png` for this folder in Hero Studio. It is the page-header image, reused on the homepage and the screensaver; the page overlays live stats on its lower third, so the image carries the title and artwork only.++New app/skill heroes MUST come from Hero Studio (the server rejects anything else with HERO_STUDIO_REQUIRED; the studio stamps provenance into the PNG and exports at 2000x1250):++1. `adom-wiki pkg install adom/hero-studio`+2. start it (`hero-studio`, port 8846) and open the webview so the human sees it+3. pose the hero (the UI, or POST /state), then Generate+4. copy `generated/latest.png` here as `docs/hero.png`
docs/hero.pngadded⋯ 1 unchanged line ⋯
icon.svgadded+5@@ -0,0 +1,5 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">+ <rect x="3" y="4" width="18" height="6" rx="2"/>+ <rect x="3" y="14" width="18" height="6" rx="2"/>+ <path d="M7 7h.01M7 17h.01"/>+</svg>
install.shadded+10@@ -0,0 +1,10 @@+#!/bin/sh+set -e+BIN="$HOME/.local/bin"+mkdir -p "$BIN"+# Symlink back into the module dir so edits propagate and a reinstall does not+# clobber a running instance.+chmod +x "$PWD/app/cli.js"+ln -sfn "$PWD/app/cli.js" "$BIN/svcwatch"+echo "OK: svcwatch installed to $BIN/svcwatch"+echo "Hint: start it with \`svcwatch serve\`, or open the tab with \`svcwatch show\`"
package.jsonadded+56@@ -0,0 +1,56 @@+{+ "slug": "svcwatch",+ "title": "Service Containers",+ "type": "app",+ "version": "1.0.0",+ "description": "One dashboard for every Adom service container: what is healthy, what is down, why, and a restart button that runs the watchdog's own start command. Reads the watchdog's verdicts off disk, so it costs no extra probes and no vendor API quota.",+ "tags": [+ "cli",+ "dashboard",+ "monitoring",+ "services",+ "watchdog"+ ],+ "dependencies": {},+ "discovery_triggers": [+ "are my services up",+ "check my service containers",+ "is mouser down",+ "is digikey down",+ "is jlcpcb down",+ "service dashboard",+ "restart a service container",+ "why is my service down",+ "service health"+ ],+ "discovery_pitch": "Reach for svcwatch when someone asks whether their service containers are healthy, which one is down, or wants to restart one.",+ "sample_prompts": [+ {+ "label": "Health check",+ "prompt": "are all my service containers up?"+ },+ {+ "label": "Diagnose",+ "prompt": "why is the oauth gateway down?"+ },+ {+ "label": "Restart",+ "prompt": "restart the mouser service"+ }+ ],+ "metadata": {+ "activate": {+ "kind": "bridge",+ "skill": "svcwatch",+ "requires": [],+ "verbPrefixes": []+ }+ },+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "hero": {+ "path": "docs/hero.png"+ }+}
uninstall.shadded+5@@ -0,0 +1,5 @@+#!/bin/sh+set -e+"$HOME/.local/bin/svcwatch" stop 2>/dev/null || true+rm -f "$HOME/.local/bin/svcwatch"+echo "OK: svcwatch removed"