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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
// 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),
};
}