[object Object]

← 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

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 &amp; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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 →