[object Object]

← back to Marketing Command Center

feat(mcc): board shows IG re-pull health at a glance + Run-now fix button

21be5cca26027cf25b1bac274c63e40ff9e765d3 · 2026-07-22 12:13:05 -0700 · Steve Abrams

The daily IG media re-pull (com.steve.mcc-ig-repull) had no surface — you couldn't
see if it was running or stale. Now the Streams Board shows a health strip above
the columns: green/amber/red dot + last-run time + result, and a "Run now" button.

- ig-media-repull.js: writes data/ig-repull-status.json every run (ranAt/mode/
  recovered/graphErr/downloadDead/unrecoverable/ok).
- channels: GET /api/channels/ig-repull-status (reads it) + POST /api/channels/
  ig-repull-run (triggers --apply as a guarded child; 409 if already running).
- board.js: #bd-repull-health strip — amber if never-run or stale (>36h), red on
  graph/download errors, green when clean; "Run now" POSTs then refreshes.

Verified locally: status write, GET, POST trigger, concurrent-run 409 guard, and
the strip renders (green dot, timestamp, Run-now button).

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

Files touched

Diff

commit 21be5cca26027cf25b1bac274c63e40ff9e765d3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 12:13:05 2026 -0700

    feat(mcc): board shows IG re-pull health at a glance + Run-now fix button
    
    The daily IG media re-pull (com.steve.mcc-ig-repull) had no surface — you couldn't
    see if it was running or stale. Now the Streams Board shows a health strip above
    the columns: green/amber/red dot + last-run time + result, and a "Run now" button.
    
    - ig-media-repull.js: writes data/ig-repull-status.json every run (ranAt/mode/
      recovered/graphErr/downloadDead/unrecoverable/ok).
    - channels: GET /api/channels/ig-repull-status (reads it) + POST /api/channels/
      ig-repull-run (triggers --apply as a guarded child; 409 if already running).
    - board.js: #bd-repull-health strip — amber if never-run or stale (>36h), red on
      graph/download errors, green when clean; "Run now" POSTs then refreshes.
    
    Verified locally: status write, GET, POST trigger, concurrent-run 409 guard, and
    the strip renders (green dot, timestamp, Run-now button).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 modules/channels/index.js  | 23 +++++++++++++++++++++++
 public/panels/board.js     | 36 ++++++++++++++++++++++++++++++++++++
 scripts/ig-media-repull.js | 14 ++++++++++++++
 3 files changed, 73 insertions(+)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index cbbebdd..ae46f7c 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -693,6 +693,29 @@ module.exports = {
     // (see lib/media-cache): cached→local path synchronously, background-fetch
     // any missing so the next load self-heals. Never blocks the response.
     router.get('/outbox', (_req, res) => res.json({ items: mediaCache.localizeItems(readOutbox().slice(-100).reverse()) }));
+    // IG media re-pull health: the board shows last-run time + result at a glance
+    // (see scripts/ig-media-repull.js, run daily by com.steve.mcc-ig-repull).
+    const REPULL_STATUS = path.join(__dirname, '..', '..', 'data', 'ig-repull-status.json');
+    router.get('/ig-repull-status', (_req, res) => {
+      try { res.json(JSON.parse(fs.readFileSync(REPULL_STATUS, 'utf8'))); }
+      catch { res.json({ ranAt: null }); } // never run yet
+    });
+    // "Run now" — the fix-it affordance. Triggers a --apply re-pull as a detached
+    // child (idempotent, backs up the outbox, only touches at-risk images). Guarded
+    // against concurrent runs; returns immediately, the status file reflects the result.
+    let repullRunning = false;
+    router.post('/ig-repull-run', (_req, res) => {
+      if (repullRunning) return res.status(409).json({ ok: false, error: 'a re-pull is already running' });
+      repullRunning = true;
+      const { spawn } = require('child_process');
+      const child = spawn(process.execPath, [path.join(__dirname, '..', '..', 'scripts', 'ig-media-repull.js'), '--apply'], { cwd: path.join(__dirname, '..', '..'), detached: false });
+      let out = '';
+      child.stdout.on('data', d => { out += d; });
+      child.stderr.on('data', d => { out += d; });
+      child.on('close', code => { repullRunning = false; });
+      child.on('error', () => { repullRunning = false; });
+      res.json({ ok: true, started: true });
+    });
     // Clear outbox cards. Body: { scope } — 'staged' (default) drops everything that
     // never went live (staged / pending-connection / failed), keeping real 'posted'
     // history; 'all' wipes everything; { channel } limits it to one channel. Lets the
diff --git a/public/panels/board.js b/public/panels/board.js
index e578bb9..87876bc 100644
--- a/public/panels/board.js
+++ b/public/panels/board.js
@@ -31,6 +31,41 @@ window.MCC_PANELS['board'] = {
     $('#bd-width').value = WIDTH; setWidth(WIDTH);
     $('#bd-sort').value = SORT;
 
+    // ── IG re-pull health strip (job that keeps published-IG thumbnails durable) ──
+    // Inserted once above the board; shows last-run time + result + a health dot,
+    // and a "Run now" fix-it button. Amber if stale (>36h) or never run, red on
+    // Graph/download errors, green when the last run completed clean.
+    if (!$('#bd-repull-health')) {
+      const strip = document.createElement('div');
+      strip.id = 'bd-repull-health';
+      strip.style.cssText = 'display:flex;align-items:center;gap:10px;margin:0 0 10px;padding:7px 12px;border:1px solid #e4ddcd;border-radius:8px;background:#faf7f0;font-size:12.5px;color:#6b6350';
+      board.parentNode.insertBefore(strip, board);
+    }
+    const healthEl = $('#bd-repull-health');
+    async function loadRepullHealth() {
+      if (!healthEl) return;
+      const s = await jget('/api/channels/ig-repull-status') || {};
+      const ageH = s.ranAt ? (Date.now() - new Date(s.ranAt).getTime()) / 3600000 : Infinity;
+      let dot = '#4a8f5f', label; // green
+      if (!s.ranAt) { dot = '#c9a227'; label = 'never run'; }
+      else if (s.ok === false) { dot = '#c0392b'; label = `error (graphErr ${s.graphErr}, dead ${s.downloadDead})`; }
+      else if (ageH > 36) { dot = '#c9a227'; label = `stale — last ran ${fmtWhen(s.ranAt)}`; }
+      else label = `ran ${fmtWhen(s.ranAt)} · ${s.recovered || 0} recovered`;
+      healthEl.innerHTML =
+        `<span style="width:9px;height:9px;border-radius:50%;background:${dot};flex:0 0 auto"></span>` +
+        `<span style="flex:1 1 auto">🔄 IG image re-pull · <strong>${esc(label)}</strong>${s.unrecoverable ? ` · ${s.unrecoverable} unrecoverable` : ''}</span>` +
+        `<button id="bd-repull-run" class="btn" style="font-size:12px;padding:3px 10px">Run now</button>`;
+      $('#bd-repull-run').addEventListener('click', async () => {
+        const btn = $('#bd-repull-run'); btn.disabled = true; btn.textContent = '…running';
+        try {
+          const r = await fetch(ORIGIN + '/api/channels/ig-repull-run', { method: 'POST', credentials: 'same-origin' });
+          if (r.status === 409) { btn.textContent = 'already running'; }
+          else { await new Promise(res => setTimeout(res, 4000)); await loadRepullHealth(); await load(); return; }
+        } catch { btn.textContent = 'failed'; }
+        setTimeout(() => { if ($('#bd-repull-run')) { $('#bd-repull-run').disabled = false; $('#bd-repull-run').textContent = 'Run now'; } }, 2500);
+      });
+    }
+
     let DATA = {};
     async function load() {
       board.innerHTML = '<div class="loading">Loading streams…</div>';
@@ -164,5 +199,6 @@ window.MCC_PANELS['board'] = {
     });
 
     await load();
+    loadRepullHealth();
   },
 };
diff --git a/scripts/ig-media-repull.js b/scripts/ig-media-repull.js
index 9f81246..f00b2bb 100644
--- a/scripts/ig-media-repull.js
+++ b/scripts/ig-media-repull.js
@@ -116,5 +116,19 @@ async function graphMedia(mediaId, token) {
     console.log(`\nAPPLIED: rewrote ${recovered} item mediaUrl(s). Backup: ${path.basename(bak)}`);
   }
 
+  // Persist a small status file so the board panel can surface job health at a
+  // glance (last run time + result + ok flag). ok = the run itself completed
+  // without Graph/download errors (recovered=0 is healthy — it means nothing was
+  // at risk, not a failure). Written on every run, dry or apply.
+  try {
+    const status = {
+      ranAt: new Date().toISOString(),
+      mode: APPLY ? 'apply' : 'dry-run',
+      recovered, alreadyLocal, graphErr, downloadDead: dead, unrecoverable: unrec.length,
+      ok: graphErr === 0 && dead === 0,
+    };
+    fs.writeFileSync(path.join(ROOT, 'data', 'ig-repull-status.json'), JSON.stringify(status, null, 2));
+  } catch (e) { console.error('status write failed:', e.message); }
+
   console.log(`\nsummary: recovered=${recovered} alreadyLocal=${alreadyLocal} graphErr=${graphErr} downloadDead=${dead} unrecoverable=${unrec.length}`);
 })().catch(e => { console.error('FATAL', e); process.exit(1); });

← 6d2f55b chore(mcc): daily launchd job for the IG media re-pull (Mac2  ·  back to Marketing Command Center  ·  auto-save: 2026-07-22T12:13:58 (1 files) — data/ig-repull-st 6b2db25 →