← back to AbramsEgo
auto-save: 2026-07-01T11:42:20 (8 files) — build-queue/tasks/05-git-activity-feed.md build-queue/tasks/06-hardrule-compliance-pass.md public/index.html server.js build-queue/STOP
628b7374195844c25ebee11ef65a0dd8c3754fdb · 2026-07-01 11:42:22 -0700 · Steve Abrams
Files touched
A build-queue/STOPR100 build-queue/tasks/05-git-activity-feed.md build-queue/done/05-git-activity-feed.mdR100 build-queue/tasks/06-hardrule-compliance-pass.md build-queue/failed/06-hardrule-compliance-pass.mdA maxit-tasks/07-cron-launchd-panel.mdA maxit-tasks/08-canary-verdicts-panel.mdA maxit-tasks/09-ollama-chat.mdM public/index.htmlM server.js
Diff
commit 628b7374195844c25ebee11ef65a0dd8c3754fdb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 1 11:42:22 2026 -0700
auto-save: 2026-07-01T11:42:20 (8 files) — build-queue/tasks/05-git-activity-feed.md build-queue/tasks/06-hardrule-compliance-pass.md public/index.html server.js build-queue/STOP
---
build-queue/STOP | 0
.../{tasks => done}/05-git-activity-feed.md | 0
.../06-hardrule-compliance-pass.md | 0
maxit-tasks/07-cron-launchd-panel.md | 14 ++
maxit-tasks/08-canary-verdicts-panel.md | 15 +++
maxit-tasks/09-ollama-chat.md | 17 +++
public/index.html | 146 ++++++++++++++++-----
server.js | 7 +-
8 files changed, 166 insertions(+), 33 deletions(-)
diff --git a/build-queue/STOP b/build-queue/STOP
new file mode 100644
index 0000000..e69de29
diff --git a/build-queue/tasks/05-git-activity-feed.md b/build-queue/done/05-git-activity-feed.md
similarity index 100%
rename from build-queue/tasks/05-git-activity-feed.md
rename to build-queue/done/05-git-activity-feed.md
diff --git a/build-queue/tasks/06-hardrule-compliance-pass.md b/build-queue/failed/06-hardrule-compliance-pass.md
similarity index 100%
rename from build-queue/tasks/06-hardrule-compliance-pass.md
rename to build-queue/failed/06-hardrule-compliance-pass.md
diff --git a/maxit-tasks/07-cron-launchd-panel.md b/maxit-tasks/07-cron-launchd-panel.md
new file mode 100644
index 0000000..d2b5f5b
--- /dev/null
+++ b/maxit-tasks/07-cron-launchd-panel.md
@@ -0,0 +1,14 @@
+# AbramsEgo maxit-07 — cron / launchd status panel (SPEC panel 4)
+
+Read ../SPEC.md. Build the scheduled-jobs panel. Keep edits MINIMAL and in clearly
+delimited regions (a merge will follow) — add ONE new route + ONE new panel only.
+
+1. server.js: add authed `GET /api/crons` — run `launchctl list` and parse Steve's
+ `com.steve.*` jobs (label, PID or `-`, last exit code); fold a compact list into the snapshot.
+2. public/index.html: add a "Cron / launchd" panel listing each job with a status pill
+ (running=green PID / idle=grey / nonzero-exit=red). Each row is an admin card → show
+ created/last-run date+time per Steve's hard rule (🕓 toLocaleString, ISO in title=).
+3. Verify with `node --check server.js` (syntax only — do NOT run the server / pm2 restart;
+ node_modules is absent in this worktree). Commit `feat(cron-launchd): scheduled-jobs panel`.
+
+Local only. No deploy/publish.
diff --git a/maxit-tasks/08-canary-verdicts-panel.md b/maxit-tasks/08-canary-verdicts-panel.md
new file mode 100644
index 0000000..bee7721
--- /dev/null
+++ b/maxit-tasks/08-canary-verdicts-panel.md
@@ -0,0 +1,15 @@
+# AbramsEgo maxit-08 — canary verdicts panel (SPEC panel 6, health)
+
+Read ../SPEC.md. Surface Steve's canary fleet health. MINIMAL edits, delimited regions
+(merge follows) — ONE new route + ONE new panel.
+
+1. server.js: add authed `GET /api/canaries` — read each `~/.claude/skills/*/data/latest.json`
+ (disk-space, swap-pressure, dw-uptime-probe, dw-five-field, dw-map-auditor, backup, meta-watchdog,
+ etc.), return {skill, verdict/status, ts, summary} sorted worst-first. Cache in snapshot
+ (stale-while-revalidate — this globs the filesystem, keep off the hot path).
+2. public/index.html: add a "Canaries" panel; each canary is an admin card with a PASS/WARN/FAIL
+ pill and its last-run date+time (🕓 toLocaleString, ISO in title=) per Steve's hard rule.
+3. Verify `node --check server.js` (syntax only; no server run / pm2). Commit
+ `feat(canaries): canary-verdicts health panel`.
+
+Local only. Read-only file globs. No deploy/publish.
diff --git a/maxit-tasks/09-ollama-chat.md b/maxit-tasks/09-ollama-chat.md
new file mode 100644
index 0000000..142b9d0
--- /dev/null
+++ b/maxit-tasks/09-ollama-chat.md
@@ -0,0 +1,17 @@
+# AbramsEgo maxit-09 — AI chat panel wired to LOCAL Ollama (SPEC panel 7, unmetered)
+
+Read ../SPEC.md. Build the natural-language chat over the snapshot. Use LOCAL Ollama so it's
+$0/unmetered and does NOT consume the Anthropic Max session cap. MINIMAL edits, delimited
+regions (merge follows) — ONE new route + ONE new panel.
+
+1. server.js: add authed `POST /api/chat {q}` — build a compact context string from the current
+ snapshot (costs, fleet, crons, sessions) and POST to Ollama:
+ endpoints in priority order `http://192.168.1.133:11434` (Mac1) then `http://localhost:11434`
+ (Mac2 fallback), model `qwen3:14b`, `stream:false`, ~8s timeout. If neither reachable, return a
+ deterministic grounded summary from the snapshot (never call any paid API). Always include a
+ cost line in the response payload: `$0 (local ollama)`.
+2. public/index.html: add an "Ask AbramsEgo" chat panel (input + answer area) calling /api/chat.
+3. Verify `node --check server.js` (syntax only; no server run / pm2). Commit
+ `feat(chat): local-ollama AI chat panel ($0, cap-relieving)`.
+
+Local only. No paid API, no keys, no deploy.
diff --git a/public/index.html b/public/index.html
index 197afc2..d82f128 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,6 +35,18 @@
.good{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.gold{color:var(--gold)}.dim{color:var(--dim)}
.row{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px dashed #ffffff08}
.row:last-child{border-bottom:0}
+ /* list row with a visible created/started date+time line (Steve's admin-card rule) */
+ .lrow{padding:5px 0;border-bottom:1px dashed #ffffff08}
+ .lrow:last-child{border-bottom:0}
+ .lrow-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
+ .lrow-when{margin-top:2px}
+ .when{font-size:9.5px;color:var(--dim);white-space:nowrap}
+ /* sort + density control bar shared by every grid/list */
+ .gridctl{display:flex;gap:12px;align-items:center;margin:0 0 10px;flex-wrap:wrap}
+ .gridctl select,.gridctl input[type=range]{background:#0c1420;border:1px solid var(--line);color:var(--ink);
+ border-radius:6px;font-family:var(--mono);font-size:11px;padding:3px 6px}
+ .gridctl label{display:flex;align-items:center;gap:5px;color:var(--dim);font-size:11px}
+ .gsentinel{grid-column:1/-1;text-align:center;color:var(--dim);font-size:10px;padding:6px}
.pill{font-size:10px;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--dim);white-space:nowrap}
.pill.gated{color:var(--warn);border-color:#ffb45444;background:#ffb4540f}
.pill.live{color:var(--good);border-color:#3fd18b44;background:#3fd18b0f}
@@ -158,12 +170,14 @@
</section>
<section class="card span7">
<h2>🧵 Recent sub-agent runs <span class="muted" id="subruns-sub"></span></h2>
+ <div id="subruns-ctl"></div>
<div class="list" id="subruns" style="max-height:340px"></div>
</section>
<!-- GIT ACTIVITY FEED (panel 6) -->
<section class="card span12">
<h2>📜 Activity <span class="muted" id="activity-sub">· recent commits across ~/Projects</span></h2>
+ <div id="activity-ctl"></div>
<div id="activity" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px;max-height:440px;overflow:auto"></div>
</section>
@@ -180,6 +194,68 @@ const $ = (id)=>document.getElementById(id);
const usd = (n)=> n==null? '—' : '$'+Number(n).toFixed(n<1?4:2);
const cls = (pct,warn,bad)=> pct>=bad?'bad':pct>=warn?'warn':'good';
+// ---- created/started date+time chip (Steve's hard admin-card rule) ----------
+// Accepts an ISO string OR an epoch-ms number/string; returns {iso,label} or null.
+function fmtWhen(v){
+ if(v==null||v==='') return null;
+ let iso = (typeof v==='number' || /^\d{10,}$/.test(String(v))) ? new Date(Number(v)).toISOString() : String(v);
+ const dt=new Date(iso); if(isNaN(dt)) return null;
+ return {iso, label: dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})};
+}
+// visible 🕓 date+time line with full ISO in title=; picks the first present field
+function whenChip(...vals){
+ for(const v of vals){ const w=fmtWhen(v); if(w) return `<span class="when" title="${esc(w.iso)}">🕓 ${esc(w.label)}</span>`; }
+ return '';
+}
+function tsOf(...vals){ for(const v of vals){ const w=fmtWhen(v); if(w) return Date.parse(w.iso); } return 0; }
+// two-line list row: main + right-meta, then a visible created/started date+time
+function lrow(main, meta, whenHtml){
+ return `<div class="lrow"><div class="lrow-top"><span>${main}</span><span class="dim">${meta||''}</span></div>`+
+ (whenHtml?`<div class="lrow-when">${whenHtml}</div>`:'')+`</div>`;
+}
+
+// ---- reusable grid: sort <select> + density slider + infinite scroll --------
+// Persists sort+density to localStorage; renders in batches with an
+// IntersectionObserver sentinel so a list can exceed ~40 rows without lag.
+const GRIDS={};
+function initGrid(name, cfg){
+ if(GRIDS[name]) return GRIDS[name];
+ const g=GRIDS[name]={items:[], cfg, shown:cfg.batch};
+ const kSort='ae.'+name+'.sort', kDens='ae.'+name+'.dens';
+ const savedSort=localStorage.getItem(kSort)||cfg.sortModes[0].k;
+ const savedDens=parseInt(localStorage.getItem(kDens)||cfg.defaultCols,10);
+ const bar=document.createElement('div'); bar.className='gridctl';
+ bar.innerHTML=`<label>sort <select class="gsort">${cfg.sortModes.map(m=>`<option value="${m.k}"${m.k===savedSort?' selected':''}>${m.label}</option>`).join('')}</select></label>`+
+ `<label>▦ <input type="range" class="gdens" min="${cfg.minCols}" max="${cfg.maxCols}" value="${savedDens}"></label>`;
+ cfg.controlsHost.appendChild(bar);
+ g.sortEl=bar.querySelector('.gsort'); g.densEl=bar.querySelector('.gdens');
+ g.sortEl.addEventListener('change',()=>{localStorage.setItem(kSort,g.sortEl.value);g.shown=cfg.batch;draw(g);});
+ g.densEl.addEventListener('input',()=>{localStorage.setItem(kDens,g.densEl.value);applyDensity(g);});
+ applyDensity(g);
+ g.io=new IntersectionObserver((ents)=>{
+ if(ents.some(e=>e.isIntersecting) && g.shown<g.items.length){ g.shown+=cfg.batch; draw(g); }
+ },{root:cfg.host, rootMargin:'140px'});
+ return g;
+}
+function applyDensity(g){
+ const cols=parseInt(g.densEl.value,10);
+ g.cfg.host.style.display='grid';
+ g.cfg.host.style.gap='8px';
+ g.cfg.host.style.gridTemplateColumns=`repeat(${cols},minmax(0,1fr))`;
+}
+function draw(g){
+ const mode=g.cfg.sortModes.find(m=>m.k===g.sortEl.value)||g.cfg.sortModes[0];
+ const items=g.items.slice().sort(mode.cmp);
+ const slice=items.slice(0,g.shown);
+ g.cfg.host.innerHTML = slice.map(g.cfg.render).join('') || `<span class="muted">${g.cfg.empty||'nothing yet'}</span>`;
+ if(g.shown<items.length){
+ const s=document.createElement('div'); s.className='gsentinel';
+ s.textContent=`… ${items.length-g.shown} more · scroll to load`;
+ g.cfg.host.appendChild(s); g.io.observe(s);
+ }
+}
+function feedGrid(name, items){ const g=GRIDS[name]; if(!g) return; g.items=Array.isArray(items)?items:[]; if(g.shown>g.items.length) g.shown=g.cfg.batch; draw(g); }
+
async function load(){
let d; try{ d = await (await fetch('/api/data')).json(); }catch(e){ return; }
if(!d || !d.builtAt) return;
@@ -192,7 +268,7 @@ async function load(){
['NODE', s.node||'—',''],
['UP', fmtDur(s.dashboardUptimeSec)||'—',''],
].map(([k,v,c])=>`<div class="metric"><div class="v ${c}">${v}</div><div class="k">${k}</div></div>`).join('');
- $('refresh').textContent = 'snapshot '+new Date(d.builtAt).toLocaleTimeString()+' · auto 15s';
+ $('refresh').textContent = 'snapshot '+new Date(d.builtAt).toLocaleTimeString()+' · auto 15s · $0 (local)';
// ---- P&L ----
const p=(d.pnl&&d.pnl.today)||{}, c=(d.cost&&d.cost.today)||{};
@@ -233,16 +309,16 @@ async function load(){
// ---- fleet ----
const k=d.kamatera||{}, l=d.localFleet||{};
$('kam-up').innerHTML=`<span class="${cls(100-(k.up/(k.total||1)*100),20,50)}">${k.up??'—'}</span><span class="dim" style="font-size:16px">/${k.total??'—'} up</span>`;
- $('kam-list').innerHTML=(k.procs||[]).slice(0,40).map(pr=>`<div class="row"><span>${statusDot(pr.status)}${pr.name}</span><span class="dim">${pr.restarts??''}↺</span></div>`).join('')|| (k.note?`<span class="muted">${k.note}</span>`:'');
+ $('kam-list').innerHTML=(k.procs||[]).slice(0,40).map(pr=>lrow(`${statusDot(pr.status)}${esc(pr.name)}`,`${pr.restarts??''}↺`,whenChip(pr.startedAt))).join('')|| (k.note?`<span class="muted">${esc(k.note)}</span>`:'');
$('loc-up').innerHTML=`<span class="good">${l.up??'—'}</span><span class="dim" style="font-size:16px">/${l.total??'—'} up</span>`;
- $('loc-list').innerHTML=(l.procs||[]).map(pr=>`<div class="row"><span>${statusDot(pr.status)}${pr.name}</span><span class="dim">${pr.memMB!=null?pr.memMB+'M':''}</span></div>`).join('')||'<span class="muted">no local pm2 procs</span>';
+ $('loc-list').innerHTML=(l.procs||[]).map(pr=>lrow(`${statusDot(pr.status)}${esc(pr.name)}`,`${pr.memMB!=null?pr.memMB+'M':''}`,whenChip(pr.startedAt))).join('')||'<span class="muted">no local pm2 procs</span>';
// ---- canaries ----
const cans=(d.canaries&&d.canaries.canaries)||[];
$('canaries').innerHTML=cans.map(cn=>{
const v=(cn.verdict||'').toUpperCase();
const col=/FAIL|DOWN|CRIT/.test(v)?'bad':/WARN|DEGRAD/.test(v)?'warn':/PASS|UP|HEALTH|OK/.test(v)?'good':'dim';
- return `<div class="row"><span>${cn.name}</span><span class="${col}">${cn.verdict||'—'} <span class="dim">${cn.ageMin}m${cn.stale?' ⚠':''}</span></span></div>`;
+ return lrow(esc(cn.name),`<span class="${col}">${esc(cn.verdict||'—')}</span> <span class="dim">${cn.ageMin}m${cn.stale?' ⚠':''}</span>`,whenChip(cn.mtime,cn.scanned));
}).join('')||'<span class="muted">no canaries</span>';
// ---- crons / officers ----
@@ -255,11 +331,11 @@ async function load(){
// ---- wins ----
const w=d.wins||{};
$('wins-today').textContent=`· ${w.today||0} today · ${w.count||0} total`;
- $('wins').innerHTML=(w.recent||[]).map(x=>`<div class="row"><span>${esc(x.title)} <span class="dim">${x.project||''}</span></span><span class="dim">${(x.date||'').slice(5,10)}</span></div>`).join('')||'<span class="muted">no wins logged</span>';
+ $('wins').innerHTML=(w.recent||[]).map(x=>lrow(esc(x.title),`<span class="dim">${esc(x.project||'')}</span>`,whenChip(x.createdAt,x.date))).join('')||'<span class="muted">no wins logged</span>';
// ---- sessions ----
const ss=(d.sessions&&d.sessions.recent)||[];
- $('sessions').innerHTML=ss.map(x=>`<div class="row"><span>${esc(x.title||x.cwd||'session')}</span><span class="dim">${x.msgCount!=null?x.msgCount+' msg':''}</span></div>`).join('')||'<span class="muted">no sessions</span>';
+ $('sessions').innerHTML=ss.map(x=>lrow(esc(x.title||x.cwd||'session'),`${x.msgCount!=null?x.msgCount+' msg':''}`,whenChip(x.lastTs,x.mtime,x.firstTs))).join('')||'<span class="muted">no sessions</span>';
// ---- alert banner ----
const alerts=[];
@@ -327,39 +403,47 @@ function renderUsage(d){
`<div class="bar"><span style="width:${Math.round(m.cost/mmax*100)}%"></span></div>`;
}).join('')) || '<span class="muted">no model usage</span>';
}
- // recent sub-agent runs — each row is an ADMIN CARD → MUST show created date+time
- const rel=$('subruns');
- if(rel){
- rel.innerHTML=runs.map(r=>{
- const iso=r.startedAt||r.updatedAt||'';
- const dt=iso?new Date(iso):null;
- const when=dt?dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
- return `<div class="subcard" title="${esc(iso)}">
- <div class="subcard-top"><span class="subcard-proj">${esc(r.project||'?')}</span>`+
- `<span class="pill">${esc(r.model||'unknown')}</span></div>`+
- `<div class="subcard-task">${esc(r.task||'')}</div>`+
- `<div class="subcard-when">🕓 ${esc(when)}</div></div>`;
- }).join('')||'<span class="muted">no sub-agent runs</span>';
- }
+ // recent sub-agent runs — each is an ADMIN CARD → shows created date+time;
+ // grid gets sort + density + infinite scroll.
+ initGrid('subruns',{
+ controlsHost:$('subruns-ctl'), host:$('subruns'), batch:10, minCols:1, maxCols:3, defaultCols:1,
+ empty:'no sub-agent runs',
+ sortModes:[
+ {k:'new', label:'Newest', cmp:(a,b)=>tsOf(b.startedAt,b.updatedAt)-tsOf(a.startedAt,a.updatedAt)},
+ {k:'old', label:'Oldest', cmp:(a,b)=>tsOf(a.startedAt,a.updatedAt)-tsOf(b.startedAt,b.updatedAt)},
+ {k:'proj', label:'Project A→Z', cmp:(a,b)=>(a.project||'').localeCompare(b.project||'')},
+ {k:'model',label:'Model A→Z', cmp:(a,b)=>(a.model||'').localeCompare(b.model||'')},
+ ],
+ render:(r)=>`<div class="subcard" title="${esc(r.startedAt||r.updatedAt||'')}">
+ <div class="subcard-top"><span class="subcard-proj">${esc(r.project||'?')}</span>`+
+ `<span class="pill">${esc(r.model||'unknown')}</span></div>`+
+ `<div class="subcard-task">${esc(r.task||'')}</div>`+
+ `<div class="subcard-when">${whenChip(r.startedAt,r.updatedAt)||'🕓 —'}</div></div>`,
+ });
+ feedGrid('subruns', runs);
}
-// git activity — each commit row is an ADMIN CARD → MUST show created date+time
+// git activity — each commit is an ADMIN CARD → shows created date+time;
+// grid gets sort + density + infinite scroll (can exceed ~40 rows).
function renderActivity(d){
const a=d.activity||{};
const commits=a.commits||[];
const sub=$('activity-sub');
- if(sub) sub.textContent=`· ${commits.length} commits · ${a.repoCount||0} repos · newest first`;
- const el=$('activity');
- if(!el) return;
- el.innerHTML=commits.map(cm=>{
- const iso=cm.date||'';
- const dt=iso?new Date(iso):null;
- const when=dt&&!isNaN(dt)?dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
- return `<div class="subcard" title="${esc(iso)}">
+ if(sub) sub.textContent=`· ${commits.length} commits · ${a.repoCount||0} repos`;
+ initGrid('activity',{
+ controlsHost:$('activity-ctl'), host:$('activity'), batch:12, minCols:1, maxCols:5, defaultCols:3,
+ empty:'no recent commits',
+ sortModes:[
+ {k:'new', label:'Newest', cmp:(x,y)=>tsOf(y.date)-tsOf(x.date)},
+ {k:'old', label:'Oldest', cmp:(x,y)=>tsOf(x.date)-tsOf(y.date)},
+ {k:'repo',label:'Repo A→Z',cmp:(x,y)=>(x.repo||'').localeCompare(y.repo||'')||tsOf(y.date)-tsOf(x.date)},
+ ],
+ render:(cm)=>`<div class="subcard" title="${esc(cm.date||'')}">
<div class="subcard-top"><span class="subcard-proj">${esc(cm.repo||'?')}</span>`+
`<span class="pill">${esc(cm.hash||'')}</span></div>`+
`<div class="subcard-task">${esc(cm.subject||'')}</div>`+
- `<div class="subcard-when">🕓 ${esc(when)}${cm.author?' · '+esc(cm.author):''}</div></div>`;
- }).join('')||'<span class="muted">no recent commits</span>';
+ `<div class="subcard-when">${whenChip(cm.date)||'🕓 —'}${cm.author?' · '+esc(cm.author):''}</div></div>`,
+ });
+ feedGrid('activity', commits);
}
function statusDot(st){const c=st==='online'?'var(--good)':st?'var(--bad)':'var(--dim)';return `<span class="dot" style="background:${c}"></span>`;}
function fmtDur(s){if(s==null)return null;if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';return Math.floor(s/3600)+'h';}
diff --git a/server.js b/server.js
index e317052..f8cb99a 100644
--- a/server.js
+++ b/server.js
@@ -118,6 +118,8 @@ async function collectLocalFleet() {
memMB: p.monit ? round(p.monit.memory / 1e6, 0) : null,
restarts: p.pm2_env && p.pm2_env.restart_time,
uptimeMs: p.pm2_env && p.pm2_env.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : null,
+ // created/started time — every fleet tile obeys Steve's admin-card date+time rule
+ startedAt: p.pm2_env && p.pm2_env.pm_uptime ? new Date(p.pm2_env.pm_uptime).toISOString() : null,
}));
return { procs, up: procs.filter((p) => p.status === 'online').length, total: procs.length };
}
@@ -135,7 +137,7 @@ async function collectKamateraFleet() {
if (r.ok) {
try {
const list = JSON.parse(r.out);
- const procs = list.map((p) => ({ name: p.name, status: p.pm2_env && p.pm2_env.status, restarts: p.pm2_env && p.pm2_env.restart_time }));
+ const procs = list.map((p) => ({ name: p.name, status: p.pm2_env && p.pm2_env.status, restarts: p.pm2_env && p.pm2_env.restart_time, startedAt: p.pm2_env && p.pm2_env.pm_uptime ? new Date(p.pm2_env.pm_uptime).toISOString() : null }));
fs.writeFileSync(KAM_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), procs }));
} catch (e) { /* keep last-good */ }
}
@@ -162,7 +164,8 @@ async function collectCanaries() {
scanned = j.scanned_at || j.checked_at || null;
} catch (e) {}
const ageMin = Math.round((Date.now() - st.mtimeMs) / 60000);
- out.push({ name, verdict, scanned, ageMin, stale: ageMin > 24 * 60 });
+ // heartbeat/last-run time so each canary card can show created date+time
+ out.push({ name, verdict, scanned, mtime: new Date(st.mtimeMs).toISOString(), ageMin, stale: ageMin > 24 * 60 });
} catch (e) { /* no heartbeat */ }
}
out.sort((a, b) => a.ageMin - b.ageMin);
← ce2face feat(cron-launchd): scheduled-jobs panel
·
back to AbramsEgo
·
queue: re-split timed-out 06 into 06a (card date+time) + 06b 6268895 →