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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
<!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>