[object Object]

← back to Norma

Norma dashboard: enrich GET / with insights + media grid (cache-backed)

a1a4ebcb0def52ff3cd00370aa37ea9997e6fe96 · 2026-07-10 12:01:22 -0700 · Steve Abrams

Steve: dashboard was 'very little info, no images'. Now:
- shared agent-base gains a generic dashboard() hook: renders a metrics[]
  stat-card row + a media[] thumbnail grid + live/simulation badge. Base
  stays generic (no IG specifics); agents supply the data.
- instagram-agent supplies dashboard(): reads cache-backed monitor insights
  (reach/impressions/followers/etc) + discover media. DTD-committed (2/2)
  CACHE design: crons persist latest result to data/*.json; GET / reads the
  cache and NEVER fires a metered Graph API call from a page view. Simulation
  computes on-empty (free/local). Non-loading sim media_url -> placeholder tile.
- data/ gitignored (runtime cache).

Verified (simulation): mode badge, 6 metric cards, media grid w/ captions,
cache files written, GET / 200 authed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a1a4ebcb0def52ff3cd00370aa37ea9997e6fe96
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 10 12:01:22 2026 -0700

    Norma dashboard: enrich GET / with insights + media grid (cache-backed)
    
    Steve: dashboard was 'very little info, no images'. Now:
    - shared agent-base gains a generic dashboard() hook: renders a metrics[]
      stat-card row + a media[] thumbnail grid + live/simulation badge. Base
      stays generic (no IG specifics); agents supply the data.
    - instagram-agent supplies dashboard(): reads cache-backed monitor insights
      (reach/impressions/followers/etc) + discover media. DTD-committed (2/2)
      CACHE design: crons persist latest result to data/*.json; GET / reads the
      cache and NEVER fires a metered Graph API call from a page view. Simulation
      computes on-empty (free/local). Non-loading sim media_url -> placeholder tile.
    - data/ gitignored (runtime cache).
    
    Verified (simulation): mode badge, 6 metric cards, media grid w/ captions,
    cache files written, GET / 200 authed.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                       |  3 ++
 agents/instagram-agent/server.js | 66 ++++++++++++++++++++++++++++++++++++++++
 agents/shared/agent-base.js      | 38 ++++++++++++++++++++++-
 3 files changed, 106 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index c0b973b..7dabd2d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,6 @@ copy-of-*
 # Per-user secrets — never commit
 docs/sessions/NORMA-NEW-USERS-*.md
 docs/sessions/NORMA-WAKEUP-*.md
+
+# agent runtime cache (dashboard data source)
+agents/*/data/
diff --git a/agents/instagram-agent/server.js b/agents/instagram-agent/server.js
index 87fba6f..87082d9 100644
--- a/agents/instagram-agent/server.js
+++ b/agents/instagram-agent/server.js
@@ -25,9 +25,73 @@ const report = require('./skills/report');
 const PORT = 9810;
 const AGENT_NAME = 'instagram-agent';
 
+// ──────────────────────────────────────
+// Dashboard data source (for GET / in agent-base).
+// Cache-backed: cron runs persist latest results to data/*.json; the
+// dashboard READS the cache and never triggers a Meta Graph API call from a
+// page view. In simulation mode (free/local) it computes on-the-fly if the
+// cache is empty. (DTD verdict, 2026-07-10.)
+// ──────────────────────────────────────
+const fs = require('fs');
+const path = require('path');
+const DATA_DIR = path.join(__dirname, 'data');
+try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch { /* ignore */ }
+const cachePath = (k) => path.join(DATA_DIR, `latest-${k}.json`);
+function writeCache(k, v) {
+  try { fs.writeFileSync(cachePath(k), JSON.stringify({ at: new Date().toISOString(), data: v })); }
+  catch (e) { console.error(`[${AGENT_NAME}] cache write ${k} failed:`, e.message); }
+}
+function readCache(k) {
+  try { return JSON.parse(fs.readFileSync(cachePath(k), 'utf8')); } catch { return null; }
+}
+const liveMode = () => !!(process.env.IG_USER_ID && process.env.IG_ACCESS_TOKEN);
+
+async function dashboard() {
+  const mode = liveMode() ? 'live' : 'simulation';
+  let mon = readCache('monitor');
+  let disc = readCache('discover');
+  if (mode === 'simulation') {
+    if (!mon) { const r = await monitor({ push_to_pulse: false }); writeCache('monitor', r); mon = readCache('monitor'); }
+    if (!disc) { const r = await discover({ limit: 9, push_to_pulse: false }); writeCache('discover', r); disc = readCache('discover'); }
+  }
+
+  const num = (n) => (typeof n === 'number' ? n.toLocaleString() : (n == null ? '—' : String(n)));
+  const ins = (mon && mon.data && mon.data.insights) || {};
+  const acct = (mon && mon.data && mon.data.account) || {};
+  const metrics = mon ? [
+    { label: 'Followers', value: num(acct.followers_count ?? ins.follower_count) },
+    { label: 'Reach', value: num(ins.reach), sub: mon.data.period ? `per ${mon.data.period}` : '' },
+    { label: 'Impressions', value: num(ins.impressions) },
+    { label: 'Profile views', value: num(ins.profile_views) },
+    { label: 'Posts', value: num(acct.media_count) },
+    { label: 'Website clicks', value: num(ins.website_clicks) },
+  ] : [];
+
+  const rawMedia = (disc && disc.data && Array.isArray(disc.data.media)) ? disc.data.media : [];
+  const media = rawMedia.slice(0, 9).map((m) => ({
+    // simulation media_url is a non-loading example.com stub → empty thumb → placeholder tile
+    thumb: (m.media_url && !/example\.com/.test(m.media_url)) ? m.media_url : '',
+    caption: m.caption,
+    permalink: m.permalink,
+    type: m.media_type,
+    when: m.timestamp,
+  }));
+
+  let note = '';
+  if (mode === 'simulation') {
+    note = 'Simulation mode — set IG_USER_ID + IG_ACCESS_TOKEN for live data. Figures and media are representative samples.';
+  } else if (!mon && !disc) {
+    note = 'Live mode — awaiting next scheduled refresh (monitor every 30m · discover every 3h).';
+  }
+  const checkedAt = mon && (mon.data.checked_at || mon.at);
+  if (checkedAt) note += (note ? '  ·  ' : '') + 'Insights as of ' + new Date(checkedAt).toLocaleString();
+  return { mode, metrics, media, note };
+}
+
 const { app, start, scheduler } = createAgentServer({
   name: AGENT_NAME,
   port: PORT,
+  dashboard,
   skills: {
     discover,
     post,
@@ -42,6 +106,7 @@ const { app, start, scheduler } = createAgentServer({
         console.log(`[${AGENT_NAME}] Cron: monitoring account insights...`);
         try {
           const result = await monitor({ push_to_pulse: true });
+          writeCache('monitor', result);
           console.log(`[${AGENT_NAME}] Cron monitor complete: reach=${result.insights.reach}`);
         } catch (err) {
           console.error(`[${AGENT_NAME}] Cron monitor error:`, err.message);
@@ -55,6 +120,7 @@ const { app, start, scheduler } = createAgentServer({
         console.log(`[${AGENT_NAME}] Cron: discovering recent media...`);
         try {
           const result = await discover({ limit: 10, push_to_pulse: true });
+          writeCache('discover', result);
           console.log(`[${AGENT_NAME}] Cron discover complete: ${result.count} media items`);
         } catch (err) {
           console.error(`[${AGENT_NAME}] Cron discover error:`, err.message);
diff --git a/agents/shared/agent-base.js b/agents/shared/agent-base.js
index fbff635..2b1359a 100644
--- a/agents/shared/agent-base.js
+++ b/agents/shared/agent-base.js
@@ -34,7 +34,10 @@ const AUTH_PASS = process.env.AUTH_PASSWORD || '';
  * @param {Array} [config.cronJobs=[]] - Array of { name, schedule, fn, options } for cron scheduling
  * @returns {{ app: express.Application, start: Function, scheduler: CronScheduler }}
  */
-function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
+function createAgentServer({ name, port, skills = {}, cronJobs = [], dashboard = null }) {
+  // `dashboard` (optional) is an async () => { mode, metrics:[{label,value,sub?}],
+  // media:[{thumb,caption,permalink,type,when}], note? } supplied by the agent so
+  // the shared base stays generic — it renders whatever metrics[]/media[] it gets.
   const app = express();
   // Security headers via helmet (added 2026-05-04 overnight YOLO loop)
   app.use(helmet({ contentSecurityPolicy: false }));
@@ -112,6 +115,11 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
     const cronStatus = scheduler.status();
     let audit = [];
     try { audit = await getRecentActions(name, 20); } catch { audit = []; }
+    let dash = null;
+    if (typeof dashboard === 'function') {
+      try { dash = await dashboard(); }
+      catch (e) { dash = { error: e.message }; }
+    }
     const data = {
       agent: name,
       port,
@@ -121,6 +129,7 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
       lastAction,
       cron: cronStatus,
       audit,
+      dash,
     };
     const DATA = JSON.stringify(data).replace(/</g, '\\u003c');
     res.type('html').send(`<!doctype html><html lang="en"><head>
@@ -142,6 +151,16 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
   .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}
+  .badge{padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.04em}
+  .badge.live{background:rgba(63,185,80,.15);color:var(--ok)}.badge.sim{background:rgba(210,153,34,.15);color:var(--warn)}
+  .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px}
+  .stat{background:#20262e;border:1px solid var(--line);border-radius:8px;padding:12px 14px}
+  .stat .v{font-size:22px;font-weight:700}.stat .l{color:var(--mut);font-size:12px;margin-top:2px}.stat .s{color:var(--mut);font-size:11px}
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px}
+  .tile{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#20262e;text-decoration:none;color:inherit;display:flex;flex-direction:column}
+  .tile .ph{aspect-ratio:1;width:100%;object-fit:cover;display:block;background:linear-gradient(135deg,#2a3340,#3a2a40)}
+  .tile .cap{padding:8px 10px;font-size:12px;color:var(--fg);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .tile .meta{padding:0 10px 8px;font-size:11px;color:var(--mut)}
 </style></head><body>
 <header>
   <h1><span id="dot" class="dot" style="background:var(--mut)"></span>${name}</h1>
@@ -149,6 +168,11 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
   <span class="sub" id="uptime"></span>
 </header>
 <main>
+  <div class="card"><h2>Account insights <span id="modeBadge"></span></h2>
+    <div id="stats" class="stats"><span class="muted">loading…</span></div>
+    <div id="dashNote" class="sub" style="margin-top:10px"></div></div>
+  <div class="card"><h2>Recent media</h2>
+    <div id="media" class="grid"><span class="muted">loading…</span></div></div>
   <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>
@@ -171,6 +195,18 @@ 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>';
 const acts=(S.audit||[]);
 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>';
+// --- Account insights + media (from the agent's dashboard() hook) ---
+const D=S.dash||{};
+const mode=D.mode||(D.error?'error':'unknown');
+const mb=document.getElementById('modeBadge');
+if(mb)mb.innerHTML=mode==='live'?'<span class="badge live">LIVE</span>':(mode==='simulation'?'<span class="badge sim">SIMULATION</span>':'');
+const stats=document.getElementById('stats');
+if(D.error){stats.innerHTML='<span style="color:var(--bad)">dashboard error: '+esc(D.error)+'</span>';}
+else{const ms=(D.metrics||[]);stats.innerHTML=ms.length?ms.map(m=>'<div class="stat"><div class="v">'+esc(m.value)+'</div><div class="l">'+esc(m.label)+'</div>'+(m.sub?'<div class="s">'+esc(m.sub)+'</div>':'')+'</div>').join(''):'<span class="muted">no metrics available</span>';}
+document.getElementById('dashNote').textContent=D.note||'';
+const media=document.getElementById('media');const mm=(D.media||[]);
+media.innerHTML=mm.length?mm.map(x=>{const cap=esc(x.caption||'');const link=x.permalink?esc(x.permalink):'#';const thumb=x.thumb?esc(x.thumb):'';const img=thumb?'<img class="ph" data-fallback="1" src="'+thumb+'" loading="lazy">':'<div class="ph"></div>';return '<a class="tile" href="'+link+'" target="_blank" rel="noopener noreferrer">'+img+'<div class="cap">'+(cap||'<span class="muted">no caption</span>')+'</div><div class="meta">'+esc(x.type||'')+(x.when?' · '+fmt(x.when):'')+'</div></a>';}).join(''):'<span class="muted">no media</span>';
+document.querySelectorAll('img.ph[data-fallback]').forEach(im=>{im.onerror=function(){const d=document.createElement('div');d.className='ph';im.replaceWith(d);};});
 setTimeout(()=>location.reload(),15000);
 </script></body></html>`);
   });

← 46eb638 Norma instagram-agent: add RECOVERY.md (deps-location gotcha  ·  back to Norma  ·  dashboard: harden escaping for attribute context (contrarian 2439b98 →