← 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
M data/local-fleet.jsonM public/index.htmlM server.js
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 →