← 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
M .gitignoreM agents/instagram-agent/server.jsM agents/shared/agent-base.js
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 & 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 →