← back to AbramsEgo
feat(canaries): canary-verdicts health panel
497cc601e492da230f2a07070b61847ca859fe4a · 2026-07-01 11:21:10 -0700 · Steve
Files touched
M public/index.htmlM server.js
Diff
commit 497cc601e492da230f2a07070b61847ca859fe4a
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 1 11:21:10 2026 -0700
feat(canaries): canary-verdicts health panel
---
public/index.html | 27 ++++++++++++++++++++-------
server.js | 50 +++++++++++++++++++++++++++++++++++++++++---------
2 files changed, 61 insertions(+), 16 deletions(-)
diff --git a/public/index.html b/public/index.html
index 0540bfb..b956799 100644
--- a/public/index.html
+++ b/public/index.html
@@ -38,6 +38,9 @@
.pill{font-size:10px;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--dim);white-space:nowrap}
.pill.gated{color:var(--warn);border-color:#ffb45444;background:#ffb4540f}
.pill.live{color:var(--good);border-color:#3fd18b44;background:#3fd18b0f}
+ .pill.pass{color:var(--good);border-color:#3fd18b44;background:#3fd18b0f}
+ .pill.warn{color:var(--warn);border-color:#ffb45444;background:#ffb4540f}
+ .pill.fail{color:var(--bad);border-color:#ff5d6c44;background:#ff5d6c0f}
.bar{height:6px;border-radius:6px;background:#ffffff10;overflow:hidden;margin-top:6px}
.bar>span{display:block;height:100%;background:var(--accent)}
.pnlgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
@@ -121,10 +124,10 @@
<div class="list" id="loc-list" style="margin-top:8px"></div>
</section>
- <!-- CANARIES -->
+ <!-- CANARIES (admin cards: PASS/WARN/FAIL pill + last-run date+time) -->
<section class="card span4">
- <h2>🐤 Canary board</h2>
- <div class="list" id="canaries"></div>
+ <h2>🐤 Canaries <span class="muted" id="can-sub"></span></h2>
+ <div class="list" id="canaries" style="max-height:340px"></div>
</section>
<!-- SCHEDULE + OFFICERS -->
@@ -228,12 +231,22 @@ async function load(){
$('loc-up').innerHTML=`<span class="good">${l.up??'—'}</span><span class="dim" style="font-size:16px">/${l.total??'—'} up</span>`;
$('loc-list').innerHTML=(l.procs||[]).map(pr=>`<div class="row"><span>${statusDot(pr.status)}${pr.name}</span><span class="dim">${pr.memMB!=null?pr.memMB+'M':''}</span></div>`).join('')||'<span class="muted">no local pm2 procs</span>';
- // ---- canaries ----
+ // ---- canaries — each is an ADMIN CARD → MUST show last-run date+time ----
const cans=(d.canaries&&d.canaries.canaries)||[];
+ const cFail=(d.canaries&&d.canaries.fail)||0, cWarn=(d.canaries&&d.canaries.warn)||0;
+ $('can-sub').textContent=`· ${cans.length} monitors · ${cFail} fail · ${cWarn} warn`;
$('canaries').innerHTML=cans.map(cn=>{
- const v=(cn.verdict||'').toUpperCase();
- const col=/FAIL|DOWN|CRIT/.test(v)?'bad':/WARN|DEGRAD/.test(v)?'warn':/PASS|UP|HEALTH|OK/.test(v)?'good':'dim';
- return `<div class="row"><span>${cn.name}</span><span class="${col}">${cn.verdict||'—'} <span class="dim">${cn.ageMin}m${cn.stale?' ⚠':''}</span></span></div>`;
+ const v=(cn.verdict||'UNKNOWN').toUpperCase();
+ const pc=v==='FAIL'?'fail':v==='WARN'?'warn':v==='PASS'?'pass':'';
+ const iso=cn.ts||'';
+ const dt=iso?new Date(iso):null;
+ const when=dt&&!isNaN(dt)?dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
+ const nm=(cn.skill||cn.name||'?').replace(/^dw-/,'').replace(/-/g,' ');
+ return `<div class="subcard" title="${esc(iso)}">
+ <div class="subcard-top"><span class="subcard-proj">${esc(nm)}</span>`+
+ `<span class="pill ${pc}">${esc(v)}</span></div>`+
+ (cn.summary?`<div class="subcard-task">${esc(cn.summary)}</div>`:'')+
+ `<div class="subcard-when">🕓 ${esc(when)}${cn.stale?' <span class="bad">· stale</span>':''}</div></div>`;
}).join('')||'<span class="muted">no canaries</span>';
// ---- crons / officers ----
diff --git a/server.js b/server.js
index aad33c4..f75ae6a 100644
--- a/server.js
+++ b/server.js
@@ -146,6 +146,33 @@ async function collectKamateraFleet() {
} catch (e) { return { procs: [], up: 0, total: 0, note: 'no cached kamatera snapshot yet' }; }
}
+// Normalize the many latest.json shapes canaries write into a single PASS/WARN/
+// FAIL verdict + severity rank (0=worst) so the panel can sort worst-first and
+// render one consistent pill. Falls back to numeric roll-ups (down/degraded/up).
+function canaryVerdict(j) {
+ if (!j || typeof j !== 'object') return { label: 'UNKNOWN', raw: null, rank: 3 };
+ const raw = (j.verdict || j.status || j.state || j.result || '').toString();
+ const hay = raw.toUpperCase();
+ if (/FAIL|DOWN|CRIT|ERROR|BROKEN|DEAD/.test(hay)) return { label: 'FAIL', raw, rank: 0 };
+ if (/WARN|DEGRAD|STALE|PRESSURE/.test(hay)) return { label: 'WARN', raw, rank: 1 };
+ if (/PASS|OK|UP|HEALTH|CLEAN|GREEN|GOOD/.test(hay)) return { label: 'PASS', raw, rank: 2 };
+ const down = Number(j.down || j.fail || j.failing || 0);
+ const degraded = Number(j.degraded || j.warn || j.warnings || 0);
+ if (down > 0) return { label: 'FAIL', raw: raw || `${down} down`, rank: 0 };
+ if (degraded > 0) return { label: 'WARN', raw: raw || `${degraded} degraded`, rank: 1 };
+ if (j.up != null || raw) return { label: 'PASS', raw: raw || 'up', rank: 2 };
+ return { label: 'UNKNOWN', raw: null, rank: 3 };
+}
+// A short human-readable line for the card body — prefer an explicit message,
+// otherwise fold whatever count fields exist into a compact roll-up.
+function canarySummary(j) {
+ if (!j || typeof j !== 'object') return '';
+ const cand = j.summary || j.message || j.msg || j.note || j.reason || j.detail || j.details;
+ if (cand) return String(cand).replace(/\s+/g, ' ').trim().slice(0, 160);
+ const bits = [];
+ for (const k of ['up', 'down', 'degraded', 'warn', 'fail', 'checked', 'total']) if (j[k] != null) bits.push(`${k} ${j[k]}`);
+ return bits.join(' · ').slice(0, 160);
+}
async function collectCanaries() {
const glob = path.join(HOME, '.claude', 'skills');
let names = [];
@@ -155,18 +182,23 @@ async function collectCanaries() {
const f = path.join(glob, name, 'data', 'latest.json');
try {
const st = fs.statSync(f);
- let verdict = null, scanned = null;
- try {
- const j = JSON.parse(fs.readFileSync(f, 'utf8'));
- verdict = j.verdict || j.status || (j.down > 0 ? 'DOWN' : j.degraded > 0 ? 'DEGRADED' : (j.up != null ? 'UP' : null));
- scanned = j.scanned_at || j.checked_at || null;
- } catch (e) {}
+ let raw = null;
+ try { raw = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) {}
+ const v = canaryVerdict(raw);
+ const summary = canarySummary(raw);
+ // last-run timestamp: prefer the canary's own stamp, else the file mtime.
+ const ts = (raw && (raw.scanned_at || raw.checked_at || raw.ts || raw.timestamp)) || new Date(st.mtimeMs).toISOString();
const ageMin = Math.round((Date.now() - st.mtimeMs) / 60000);
- out.push({ name, verdict, scanned, ageMin, stale: ageMin > 24 * 60 });
+ out.push({ skill: name, name, verdict: v.label, rawVerdict: v.raw, severity: v.rank, summary, ts, scanned: ts, ageMin, stale: ageMin > 24 * 60 });
} catch (e) { /* no heartbeat */ }
}
- out.sort((a, b) => a.ageMin - b.ageMin);
- return { canaries: out, count: out.length };
+ // worst-first: FAIL(0) → WARN(1) → PASS(2) → UNKNOWN(3), freshest within a bucket
+ out.sort((a, b) => (a.severity - b.severity) || (a.ageMin - b.ageMin));
+ return {
+ canaries: out, count: out.length,
+ fail: out.filter((c) => c.severity === 0).length,
+ warn: out.filter((c) => c.severity === 1).length,
+ };
}
async function collectCrons() {
← d24ccf1 feat: cross-repo git activity feed
·
back to AbramsEgo
·
chore: gitignore ephemeral data/activity.json snapshot cache f5a8b07 →