main
John Lauer Publish 1.0.0 2faf052 1mo ago
// 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); });
}