← back to Cronjobs Viewer
public/index.html
319 lines
<!doctype html>
<html lang="en">
<head>
<link rel="icon" href="/favicon.ico">
<script src="/drill.js"></script>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>cronjobs.agentabrams.com — launchd cron calendar (macstudio3)</title>
<style>
:root{
--bg:#0b0d10; --panel:#14181d; --panel2:#1b2027; --line:#262d36;
--ink:#e8edf2; --dim:#8b96a3; --teal:#2dd4bf; --amber:#f5b301;
--red:#f87171; --green:#34d399; --blue:#60a5fa;
--cols:4;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
header{position:sticky;top:0;z-index:10;background:linear-gradient(180deg,#0b0d10,#0b0d10ee);border-bottom:1px solid var(--line);padding:14px 20px}
h1{margin:0;font-size:16px;font-weight:600;letter-spacing:.3px}
h1 .host{color:var(--teal)}
.sub{color:var(--dim);font-size:12px;margin-top:3px}
.stats{display:flex;gap:14px;flex-wrap:wrap;margin-top:10px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 12px;font-size:12px}
.stat b{font-size:16px;display:block}
.stat.err b{color:var(--red)} .stat.alert b{color:var(--amber)} .stat.run b{color:var(--blue)} .stat.ok b{color:var(--green)}
.controls{display:flex;gap:12px;flex-wrap:wrap;align-items:center;padding:12px 20px;border-bottom:1px solid var(--line);background:var(--panel)}
.controls label{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;display:flex;flex-direction:column;gap:4px}
select,input[type=search]{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
input[type=search]{min-width:220px}
input[type=range]{width:130px}
.toggle{flex-direction:row!important;align-items:center;gap:6px;cursor:pointer;text-transform:none;font-size:13px;color:var(--ink)}
.toggle input{accent-color:var(--teal)}
main{padding:18px 20px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 13px;display:flex;flex-direction:column;gap:7px;position:relative;overflow:hidden}
.card.error{border-color:#5b2626;background:linear-gradient(180deg,#1b1113,#14181d)}
.card.alert{border-color:#6a4b16;background:linear-gradient(180deg,#211a0d,#14181d)}
.card.running{border-color:#22406b}
.card .label{font-weight:600;font-size:13px;word-break:break-all;padding-right:70px}
.card .short{color:var(--dim);font-weight:400}
.badge{position:absolute;top:11px;right:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;padding:3px 7px;border-radius:20px}
.badge.error{background:#5b2626;color:#fecaca} .badge.alert{background:#6a4b16;color:#fde68a} .badge.running{background:#22406b;color:#bfdbfe}
.badge.idle,.badge.active{background:#20272f;color:#9fb0c0}
/* liveness pill (true launchd run-accounting signal, not log mtime) */
.live{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 7px;border-radius:20px;display:inline-block}
.live.firing{background:#0f2e26;color:var(--green)}
.live.stale{background:#3a2410;color:var(--amber)}
.live.unknown{background:#20272f;color:var(--dim)}
.live{cursor:pointer;user-select:none}
.live:hover{filter:brightness(1.28)}
.live:focus-visible{outline:2px solid var(--teal);outline-offset:1px}
.live .reck{opacity:.5;font-size:9px}
.live.checking{background:#20272f;color:var(--dim)}
.card.stale{border-color:#4a3410}
.purpose{font-size:12px;color:#b8c2ce;line-height:1.4}
.rowset{display:flex;flex-direction:column;gap:3px;font-size:12px}
.kv{display:flex;gap:6px}
.kv .k{color:var(--dim);min-width:74px;text-transform:uppercase;font-size:10px;letter-spacing:.5px;padding-top:1px}
.kv .v{color:var(--ink);word-break:break-word}
.exit0{color:var(--green)} .exitN{color:var(--red);font-weight:600} .exitDash{color:var(--dim)}
.chip{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:11px}
.model{color:var(--amber)}
.when{font-size:11px;color:var(--dim)}
.empty{color:var(--dim);text-align:center;padding:40px}
.foot{color:var(--dim);font-size:11px;padding:16px 20px}
</style>
</head>
<body>
<header>
<h1>🗓️ <span class="host">cronjobs.agentabrams.com</span> — launchd cron calendar <span class="short" style="color:var(--dim);font-weight:400">· macstudio3</span></h1>
<div class="sub">Every <code>com.steve.*</code> LaunchAgent, introspected live. Schedule · last/next run · exit code · state · program · model.</div>
<div class="stats" id="stats"></div>
</header>
<div class="controls">
<label>Sort
<select id="sort">
<option value="state">Errors first</option>
<option value="stale">Stale first</option>
<option value="label">Label A→Z</option>
<option value="runs">Runs ↓</option>
<option value="nextRun">Next run ↑</option>
<option value="lastLog">Last log write ↓</option>
<option value="schedule">Schedule</option>
<option value="exit">Exit code</option>
<option value="created">Newest created</option>
</select>
</label>
<label>State
<select id="fstate">
<option value="">All states</option>
<option value="error">Error</option>
<option value="alert">Alert</option>
<option value="running">Running</option>
<option value="active">Active</option>
</select>
</label>
<label>Liveness
<select id="flive">
<option value="">All liveness</option>
<option value="firing">Firing</option>
<option value="stale">Stale</option>
<option value="unknown">Unknown</option>
</select>
</label>
<label>Cadence
<select id="fcad">
<option value="">All cadences</option>
<option value="daily">Daily</option>
<option value="every">Interval</option>
<option value="at load">At load</option>
</select>
</label>
<label>Search
<input type="search" id="q" placeholder="label / purpose / program…"/>
<input type="hidden" id="pattern" value="">
</label>
<label class="toggle"><input type="checkbox" id="failonly"/> Failing only</label>
<label>Density
<input type="range" id="density" min="1" max="6" step="1" value="4"/>
</label>
<label class="toggle" style="margin-left:auto"><input type="checkbox" id="auto" checked/> Auto-refresh 30s</label>
</div>
<main>
<div class="grid" id="grid"><div class="empty">Loading live launchd data…</div></div>
</main>
<div class="foot" id="foot"></div>
<script>
// creds-in-URL fetch guard already installed in the <head> (canonical fleet
// drop-in — handles string + Request, preserves pathname). A second, weaker
// copy here used to double-wrap window.fetch; removed 2026-08-25 (/5x contrarian
// gate). Do NOT re-add — the head guard is the single source of truth.
// href-drill primitives (TK-10903)
var FIELDS=[['state','State'],['liveness','Liveness']];
var FIL={state:'',liveness:''};
function esc(s){return String(s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
function applyFilters(push){if(push)writeURL(true);apply();}
const $ = (s)=>document.querySelector(s);
let DATA = {jobs:[]};
// persisted controls
const LS = {
get:(k,d)=>{try{const v=localStorage.getItem('cronviewer:'+k);return v===null?d:v}catch(e){return d}},
set:(k,v)=>{try{localStorage.setItem('cronviewer:'+k,v)}catch(e){}}
};
['sort','fstate','fcad','flive'].forEach(id=>{ $('#'+id).value = LS.get(id,$('#'+id).value); });
$('#q').value = LS.get('q','');
$('#failonly').checked = LS.get('failonly','0')==='1';
$('#auto').checked = LS.get('auto','1')==='1';
const dens = parseInt(LS.get('density','4'),10); $('#density').value=dens;
document.documentElement.style.setProperty('--cols',dens);
function fmt(iso){
if(!iso) return null;
try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch(e){return iso}
}
function rel(iso){
if(!iso) return '';
const ms = Date.now()-new Date(iso).getTime(); const a=Math.abs(ms);
const m=Math.round(a/6e4),h=Math.round(a/36e5),d=Math.round(a/864e5);
const s = d>=1?d+'d':h>=1?h+'h':m+'m';
return ms>=0 ? '('+s+' ago)' : '(in '+s+')';
}
function exitClass(e){ if(e===null||e==='-') return 'exitDash'; return e==='0'?'exit0':'exitN'; }
function apply(){
const jobs = DATA.jobs||[];
const q = $('#q').value.trim().toLowerCase();
const fstate = $('#fstate').value, fcad=$('#fcad').value, flive=$('#flive').value, failonly=$('#failonly').checked;
let rows = jobs.filter(j=>{
if(failonly && j.state!=='error' && j.state!=='alert') return false;
if(fstate && j.state!==fstate) return false;
if(flive && (j.liveness||'unknown')!==flive) return false;
if(fcad){ const s=(j.schedule||''); if(fcad==='daily'&&!s.startsWith('daily'))return false; if(fcad==='every'&&!s.startsWith('every'))return false; if(fcad==='at load'&&s!=='at load')return false; }
if(q){ const hay=(j.label+' '+(j.purpose||'')+' '+(j.program||'')+' '+(j.model||'')).toLowerCase(); if(!hay.includes(q))return false; }
return true;
});
const sort = $('#sort').value;
const stateRank={error:0,alert:1,running:2,active:3,idle:3};
const liveRank={stale:0,unknown:1,firing:2};
rows.sort((a,b)=>{
switch(sort){
case 'label': return a.label.localeCompare(b.label);
case 'stale': return (liveRank[a.liveness||'unknown']-liveRank[b.liveness||'unknown']) || a.label.localeCompare(b.label);
case 'runs': return (b.runs||0)-(a.runs||0) || a.label.localeCompare(b.label);
case 'nextRun': return (a.nextRun||'9999').localeCompare(b.nextRun||'9999');
case 'lastLog': return (b.lastLogWrite||'').localeCompare(a.lastLogWrite||'');
case 'schedule': return (a.schedule||'~').localeCompare(b.schedule||'~');
case 'exit': return (b.lastExit==='0'?-1:1)-(a.lastExit==='0'?-1:1) || a.label.localeCompare(b.label);
case 'created': return (b.created||'').localeCompare(a.created||'');
default: return (stateRank[a.state]-stateRank[b.state]) || a.label.localeCompare(b.label);
}
});
const grid = $('#grid');
if(!rows.length){ grid.innerHTML='<div class="empty">No jobs match the current filters.</div>'; return; }
grid.innerHTML = rows.map(j=>{
const short = j.label.replace(/^com\.steve\./,'');
const ex = j.lastExit;
const live = j.liveness||'unknown';
const runsTxt = (j.runs===null||j.runs===undefined) ? '—'
: `${j.runs.toLocaleString()}${j.runsDelta>0?` <span class="when">(+${j.runsDelta} since last snapshot)</span>`:''}`;
// liveness label: firing shows it IS running even if the log mtime is frozen
const liveLabel = live==='firing'?'🟢 firing':live==='stale'?'⚠ stale':'? unknown';
return `<div class="card ${j.state} ${live==='stale'?'stale':''}" data-label="${j.label}">
${drill('state',j.state,j.state,`badge ${j.state}`)}
<div class="label">com.steve.<span class="short">${short}</span></div>
<div><span class="live ${live}" role="button" tabindex="0" data-label="${j.label}" title="click to re-check this job's live launchd run-accounting now (bypasses the 5-min cache)">${liveLabel} <span class="reck">↻</span></span></div>
${j.purpose?`<div class="purpose">${escapeHtml(j.purpose)}</div>`:''}
<div class="rowset">
<div class="kv"><span class="k">Schedule</span><span class="v"><span class="chip">${j.schedule||'—'}</span></span></div>
<div class="kv"><span class="k">Runs</span><span class="v runsv">${runsTxt}</span></div>
<div class="kv"><span class="k">Last exit</span><span class="v ${exitClass(ex)}">${ex===null?'not loaded':ex==='-'?'—':ex}${ex==='0'?' ✓':(ex&&ex!=='-'?(j.state==='alert'?' ⚠':' ✕'):'')}</span></div>
<div class="kv"><span class="k">Last log</span><span class="v" title="log mtime — frozen for silent jobs, NOT a real-run signal · ${j.lastLogWrite||''}">${fmt(j.lastLogWrite)||'—'} <span class="when">${rel(j.lastLogWrite)}</span></span></div>
<div class="kv"><span class="k">Next run</span><span class="v" title="${j.nextRun||''}">${fmt(j.nextRun)||'(interval)'} <span class="when">${rel(j.nextRun)}</span></span></div>
<div class="kv"><span class="k">Program</span><span class="v"><span class="chip">${j.program||'—'}</span></span></div>
${j.model?`<div class="kv"><span class="k">Model</span><span class="v model">${escapeHtml(j.model)}</span></div>`:''}
<div class="kv"><span class="k">Created</span><span class="v when" title="${j.created||''}">🕓 ${fmt(j.created)||'—'}</span></div>
</div>
</div>`;
}).join('');
$('#foot').textContent = `Showing ${rows.length} of ${jobs.length} jobs · snapshot ${fmt(DATA.generatedAt)||''}`;
}
function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
function stats(){
const j=DATA.jobs||[];
const err=j.filter(x=>x.state==='error').length, alert=j.filter(x=>x.state==='alert').length, run=j.filter(x=>x.state==='running').length;
const firing=j.filter(x=>x.liveness==='firing').length, stale=j.filter(x=>x.liveness==='stale').length;
$('#stats').innerHTML =
`<div class="stat"><b>${DATA.count||j.length}</b>total jobs</div>`+
`<div class="stat"><b>${DATA.loadedCount||0}</b>loaded</div>`+
`<div class="stat err"><b>${err}</b>error exit</div>`+
`<div class="stat alert"><b>${alert}</b>alert exit</div>`+
`<div class="stat run"><b>${run}</b>running</div>`+
`<div class="stat ok"><b>${firing}</b>firing</div>`+
`<div class="stat err"><b>${stale}</b>stale</div>`;
}
async function load(){
try{
const r = await fetch('api/crons',{credentials:'same-origin'});
DATA = await r.json();
stats(); apply();
}catch(e){ $('#grid').innerHTML='<div class="empty">Failed to load /api/crons — '+e.message+'</div>'; }
}
// Liveness pill → on-demand re-check. Wires the /api/liveness/:label endpoint:
// ONE fetch, patches just this card's pill + Runs counter in place (no full
// grid re-render), then refreshes the firing/stale tallies. The snapshot's
// liveness is up to 5 min old; this bypasses the cache for a single job.
async function recheckLiveness(pill){
const card = pill.closest('.card');
const label = pill.dataset.label || (card && card.dataset.label);
if(!label || pill.dataset.busy) return;
pill.dataset.busy='1';
const prevClass = pill.className, prevHtml = pill.innerHTML;
pill.className='live checking'; pill.textContent='… checking';
try{
const d = await (await fetch('api/liveness/'+encodeURIComponent(label),{credentials:'same-origin'})).json();
const job = (DATA.jobs||[]).find(x=>x.label===label);
const alive = d.running===true || (d.runsDelta||0)>0;
const live = alive ? 'firing'
: ((d.runs===null||d.runs===undefined) ? 'unknown' : ((job&&job.liveness)||'unknown'));
if(job){ if(typeof d.runs==='number') job.runs=d.runs; job.runsDelta=d.runsDelta||0; job.liveness=live; }
pill.className='live '+live; pill.dataset.label=label;
pill.innerHTML=(d.running?'🟢 running now':live==='firing'?'🟢 firing':live==='stale'?'⚠ stale':'? unknown')+' <span class="reck">↻</span>';
const rv = card && card.querySelector('.runsv');
if(rv) rv.innerHTML=(d.runs===null||d.runs===undefined) ? '—'
: `${Number(d.runs).toLocaleString()}${d.runsDelta>0?` <span class="when">(+${d.runsDelta} since last snapshot)</span>`:''}`;
stats();
}catch(_){ pill.className=prevClass; pill.innerHTML=prevHtml; }
finally{ delete pill.dataset.busy; }
}
$('#grid').addEventListener('click', e=>{ const p=e.target.closest('.live'); if(p) recheckLiveness(p); });
$('#grid').addEventListener('keydown', e=>{ if(e.key==='Enter'||e.key===' '){ const p=e.target.closest('.live'); if(p){ e.preventDefault(); recheckLiveness(p); } } });
// wire controls
['sort','fstate','fcad','flive'].forEach(id=>$('#'+id).addEventListener('change',()=>{LS.set(id,$('#'+id).value);apply()}));
$('#q').addEventListener('input',()=>{LS.set('q',$('#q').value);apply()});
$('#failonly').addEventListener('change',()=>{LS.set('failonly',$('#failonly').checked?'1':'0');apply()});
$('#density').addEventListener('input',()=>{const v=$('#density').value;document.documentElement.style.setProperty('--cols',v);LS.set('density',v)});
let timer=null;
function autotoggle(){ clearInterval(timer); if($('#auto').checked){timer=setInterval(load,30000);} LS.set('auto',$('#auto').checked?'1':'0'); }
$('#auto').addEventListener('change',autotoggle);
readURL();
load(); autotoggle();
</script>
</body>
</html>