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.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
// 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); });
}