← back to Norma
Norma agents: add read-only status dashboard at GET / (behind auth)
6f74352810d2620a8181412a05331d01b67e3251 · 2026-07-10 11:23:48 -0700 · Steve Abrams
Renders /api/status + cron + recent audit client-side; no write controls.
Fixes the 'open it in a browser -> 401/404' gap. Shared base, so all 11
agents inherit it; only norma-instagram is currently live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M agents/shared/agent-base.js
Diff
commit 6f74352810d2620a8181412a05331d01b67e3251
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 10 11:23:48 2026 -0700
Norma agents: add read-only status dashboard at GET / (behind auth)
Renders /api/status + cron + recent audit client-side; no write controls.
Fixes the 'open it in a browser -> 401/404' gap. Shared base, so all 11
agents inherit it; only norma-instagram is currently live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
agents/shared/agent-base.js | 65 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 65 insertions(+)
diff --git a/agents/shared/agent-base.js b/agents/shared/agent-base.js
index e7f35d4..d2ed295 100644
--- a/agents/shared/agent-base.js
+++ b/agents/shared/agent-base.js
@@ -102,6 +102,71 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
next();
});
+ // ──────────────────────────────────────
+ // Read-only status dashboard (GET /) — behind auth
+ // Renders /api/status + /api/cron/status + /api/audit client-side.
+ // No write controls; cron start/stop stay API/skill-only by design.
+ // ──────────────────────────────────────
+ app.get('/', (req, res) => {
+ res.type('html').send(`<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${name} — status</title>
+<style>
+ :root{--bg:#0e1116;--card:#171b22;--line:#242a33;--fg:#e6edf3;--mut:#8b949e;--ok:#3fb950;--warn:#d29922;--bad:#f85149;--accent:#58a6ff}
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+ header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
+ h1{font-size:18px;margin:0;font-weight:600}.sub{color:var(--mut);font-size:13px}
+ .dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:6px}
+ main{padding:20px 24px;display:grid;gap:18px;max-width:1000px}
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px}
+ .card h2{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin:0 0 12px}
+ table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);font-size:13px}
+ th{color:var(--mut);font-weight:500}tr:last-child td{border-bottom:0}
+ .pill{padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}
+ .pill.on{background:rgba(63,185,80,.15);color:var(--ok)}.pill.off{background:rgba(139,148,158,.15);color:var(--mut)}
+ .chips{display:flex;gap:8px;flex-wrap:wrap}.chip{background:#20262e;border:1px solid var(--line);border-radius:6px;padding:3px 10px;font-size:12px}
+ .muted{color:var(--mut)}code{background:#20262e;padding:1px 6px;border-radius:4px}
+ .foot{color:var(--mut);font-size:12px;padding:0 24px 24px}
+</style></head><body>
+<header>
+ <h1><span id="dot" class="dot" style="background:var(--mut)"></span>${name}</h1>
+ <span class="sub">port ${port} · read-only · auto-refresh 15s</span>
+ <span class="sub" id="uptime"></span>
+</header>
+<main>
+ <div class="card"><h2>Skills & monitors</h2><div id="skills" class="chips muted">loading…</div>
+ <div style="margin-top:12px" id="monitors" class="chips"></div></div>
+ <div class="card"><h2>Cron jobs</h2>
+ <table><thead><tr><th>Job</th><th>Schedule</th><th>Running</th><th>Last run</th><th>Status</th><th>Runs</th></tr></thead>
+ <tbody id="cron"><tr><td colspan="6" class="muted">loading…</td></tr></tbody></table></div>
+ <div class="card"><h2>Recent activity</h2>
+ <table><thead><tr><th>When</th><th>Action</th><th>Detail</th></tr></thead>
+ <tbody id="audit"><tr><td colspan="3" class="muted">loading…</td></tr></tbody></table></div>
+</main>
+<div class="foot">Control is API/skill-driven — see <code>/api/status</code>, <code>/api/audit?limit=50</code>, <code>/api/cron/status</code>, <code>POST /api/cron/{start,stop}</code>, or the <code>instagram-account-manager</code> skill.</div>
+<script>
+const esc=s=>String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
+const fmt=t=>{if(!t)return '<span class="muted">—</span>';const d=new Date(t);return isNaN(d)?esc(t):d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});};
+async function j(u){const r=await fetch(u,{headers:{Accept:'application/json'}});if(!r.ok)throw new Error(r.status);return r.json();}
+async function load(){
+ try{
+ const s=await j('/api/status');
+ document.getElementById('dot').style.background='var(--ok)';
+ document.getElementById('uptime').textContent='up '+Math.floor((s.uptime||0)/60)+'m';
+ document.getElementById('skills').innerHTML=(s.skills||[]).map(x=>'<span class="chip">'+esc(x)+'</span>').join('')||'<span class="muted">none</span>';
+ document.getElementById('monitors').innerHTML=(s.monitors||[]).map(x=>'<span class="chip">🟢 '+esc(x)+'</span>').join('');
+ const jobs=(s.cron&&s.cron.jobs)||[];
+ document.getElementById('cron').innerHTML=jobs.length?jobs.map(jb=>'<tr><td>'+esc(jb.name)+'</td><td><code>'+esc(jb.schedule)+'</code></td><td><span class="pill '+(jb.running?'on':'off')+'">'+(jb.running?'on':'off')+'</span></td><td>'+fmt(jb.lastRun)+'</td><td>'+(jb.lastError?'<span style="color:var(--bad)">error</span>':esc(jb.lastStatus||'—'))+'</td><td>'+(jb.runCount||0)+'</td></tr>').join(''):'<tr><td colspan="6" class="muted">no cron jobs</td></tr>';
+ }catch(e){document.getElementById('dot').style.background='var(--bad)';document.getElementById('cron').innerHTML='<tr><td colspan="6" style="color:var(--bad)">status error: '+esc(e.message)+'</td></tr>';}
+ try{
+ const a=await j('/api/audit?limit=20');const acts=(a.actions||[]);
+ document.getElementById('audit').innerHTML=acts.length?acts.map(x=>'<tr><td>'+fmt(x.timestamp||x.ts||x.time)+'</td><td>'+esc(x.action||x.type||x.event||'—')+'</td><td class="muted">'+esc(x.detail||x.summary||x.message||'')+'</td></tr>').join(''):'<tr><td colspan="3" class="muted">no recorded activity</td></tr>';
+ }catch(e){document.getElementById('audit').innerHTML='<tr><td colspan="3" class="muted">audit unavailable</td></tr>';}
+}
+load();setInterval(load,15000);
+</script></body></html>`);
+ });
+
// ──────────────────────────────────────
// Status endpoint
// ──────────────────────────────────────
← 3b2f226 Global search: mount on Supervisor + Pulse tiers
·
back to Norma
·
agent-base: fix route shadowing + server-render status dashb 3dbea14 →