[object Object]

← back to AbramsEgo

feat: cron cards show last/next run + model (SPEC panel 4)

0fa5291a7e7a98418b10d6bd795245aa4838faeb · 2026-07-01 14:20:08 -0700 · Steve

Files touched

Diff

commit 0fa5291a7e7a98418b10d6bd795245aa4838faeb
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 1 14:20:08 2026 -0700

    feat: cron cards show last/next run + model (SPEC panel 4)
---
 data/local-fleet.json |  2 +-
 public/index.html     |  5 +++++
 server.js             | 28 ++++++++++++++++++++++++++--
 3 files changed, 32 insertions(+), 3 deletions(-)

diff --git a/data/local-fleet.json b/data/local-fleet.json
index 295774e..8632d13 100644
--- a/data/local-fleet.json
+++ b/data/local-fleet.json
@@ -1 +1 @@
-{"fetchedAt":"2026-07-01T21:17:09.972Z","procs":[{"name":"pm2-logrotate","status":"online","cpu":0,"memMB":46,"restarts":0,"uptimeMs":632387,"startedAt":"2026-07-01T21:06:37.585Z"},{"name":"abramsego","status":"online","cpu":3,"memMB":63,"restarts":1,"uptimeMs":331073,"startedAt":"2026-07-01T21:11:38.899Z"},{"name":"api-token-dashboard","status":"online","cpu":1,"memMB":55,"restarts":1,"uptimeMs":197306,"startedAt":"2026-07-01T21:13:52.666Z"}]}
\ No newline at end of file
+{"fetchedAt":"2026-07-01T21:19:48.842Z","procs":[{"name":"pm2-logrotate","status":"online","cpu":0,"memMB":45,"restarts":0,"uptimeMs":791257,"startedAt":"2026-07-01T21:06:37.585Z"},{"name":"abramsego","status":"online","cpu":0,"memMB":76,"restarts":2,"uptimeMs":616,"startedAt":"2026-07-01T21:19:48.226Z"},{"name":"api-token-dashboard","status":"online","cpu":0,"memMB":56,"restarts":1,"uptimeMs":356176,"startedAt":"2026-07-01T21:13:52.666Z"}]}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 3da9989..518dfc5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -528,9 +528,14 @@ function renderCrons(d){
       const name=String(j.label||'').replace(/^com\.steve\./,'');
       const sched=j.schedule?`⏲ ${esc(j.schedule)}`:'<span class="dim">no schedule</span>';
       const prog=j.program?` · <span class="dim">${esc(j.program)}</span>`:'';
+      // SPEC panel 4: last/next run per job. Interval jobs have no knowable
+      // phase, so nextRun falls back to the schedule text.
+      const runs=`last ${whenChip(j.lastRun)||'—'} · next ${whenChip(j.nextRun)||esc(j.schedule||'—')}`;
+      const model=j.model?` <span class="pill">${esc(j.model)}</span>`:'';
       return `<div class="subcard" title="created ${esc(iso)}">
         <div class="subcard-top"><span class="subcard-proj">${esc(name)}</span>${cronPill(j)}</div>
         <div class="subcard-when" style="margin-top:6px">${sched}${prog}</div>
+        <div class="subcard-when">${runs}${model}</div>
         <div class="subcard-when">${whenChip(j.created,j.modified)||'🕓 —'}</div>
       </div>`;
     },
diff --git a/server.js b/server.js
index 321cd8f..6d97abc 100644
--- a/server.js
+++ b/server.js
@@ -198,11 +198,19 @@ async function collectCanaries() {
   return { canaries: out, count: out.length };
 }
 
+// next occurrence of HH:MM local time from now (today, or tomorrow if passed)
+function nextDailyRun(hour, minute) {
+  const now = new Date();
+  const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
+  if (next <= now) next.setDate(next.getDate() + 1);
+  return next.toISOString();
+}
+
 // parse a launchd plist for a human-readable schedule + the file's created/
 // modified timestamps (satisfies Steve's admin-card "created date+time" rule).
 // Cheap synchronous read; never throws — degrades to nulls.
 function readCronPlistMeta(filePath) {
-  const meta = { schedule: null, program: null, created: null, modified: null };
+  const meta = { schedule: null, program: null, created: null, modified: null, lastRun: null, nextRun: null, model: null };
   try {
     const st = fs.statSync(filePath);
     meta.created = new Date(st.birthtimeMs || st.ctimeMs).toISOString();
@@ -218,12 +226,28 @@ function readCronPlistMeta(filePath) {
     const h = xml.match(/<key>Hour<\/key>\s*<integer>(\d+)<\/integer>/i);
     const m = xml.match(/<key>Minute<\/key>\s*<integer>(\d+)<\/integer>/i);
     meta.schedule = h ? `daily ${String(h[1]).padStart(2, '0')}:${m ? String(m[1]).padStart(2, '0') : '00'}` : 'calendar';
+    // interval jobs have no knowable phase, so nextRun stays null for those;
+    // calendar HH:MM jobs get a real next-occurrence timestamp.
+    if (h) meta.nextRun = nextDailyRun(parseInt(h[1], 10), m ? parseInt(m[1], 10) : 0);
   } else if (/<key>RunAtLoad<\/key>\s*<true/i.test(xml)) {
     meta.schedule = 'at load';
   }
   const prog = xml.match(/<key>Program<\/key>\s*<string>([^<]+)<\/string>/i)
     || xml.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/i);
   if (prog) meta.program = prog[1].split('/').pop();
+  // lastRun (best-effort) = newest mtime of the job's stdout/stderr log file
+  let lastMs = 0;
+  for (const re of [/<key>StandardOutPath<\/key>\s*<string>([^<]+)<\/string>/i, /<key>StandardErrorPath<\/key>\s*<string>([^<]+)<\/string>/i]) {
+    const p = xml.match(re);
+    if (!p) continue;
+    try { const st = fs.statSync(p[1]); if (st.mtimeMs > lastMs) lastMs = st.mtimeMs; } catch (e) {}
+  }
+  if (lastMs) meta.lastRun = new Date(lastMs).toISOString();
+  // model (best-effort): an explicit --model arg, else any claude-* model token
+  const mArg = xml.match(/--model\s*<\/string>\s*<string>([^<]+)</i);
+  const mTok = xml.match(/\bclaude-(?:opus|sonnet|haiku|fable)[\w.-]*/i);
+  if (mArg) meta.model = mArg[1].trim();
+  else if (mTok) meta.model = mTok[0];
   return meta;
 }
 
@@ -249,7 +273,7 @@ async function collectCrons() {
     const meta = readCronPlistMeta(path.join(laDir, f));
     // running = live PID; error = loaded with a non-zero last exit; idle otherwise.
     const state = pid ? 'running' : (lastExit && lastExit !== '0' && lastExit !== '-') ? 'error' : 'idle';
-    return { label, loaded: !!l, pid, lastExit, state, schedule: meta.schedule, program: meta.program, created: meta.created, modified: meta.modified };
+    return { label, loaded: !!l, pid, lastExit, state, schedule: meta.schedule, program: meta.program, created: meta.created, modified: meta.modified, lastRun: meta.lastRun, nextRun: meta.nextRun, model: meta.model };
   });
   return { count: jobs.length, loadedCount: jobs.filter((j) => j.loaded).length, jobs };
 }

← b3fcfed chore: replenish audit (fable) — 17+18 verified live, 12/12  ·  back to AbramsEgo  ·  chore: mark task 20 done (cron cards last/next run shipped) e5711b6 →