← back to Agent Cabinet
activity.html
263 lines
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>What it's doing — Activity Timeline</title>
<style>
:root{--bg:#0b0e14;--panel:#11151f;--ink:#e7ecf3;--dim:#8a94a6;--line:#1f2735}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font:13.5px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif}
header{padding:20px 28px 12px;border-bottom:1px solid var(--line);position:sticky;top:0;background:#0b0e14;z-index:5}
h1{margin:0;font-size:21px;font-weight:600;letter-spacing:.2px}
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
.meta{color:var(--dim);font-size:12px;margin-top:5px}
.meta a{color:#34f4ce;text-decoration:none}
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:13px}
.chip{cursor:pointer;user-select:none;font-size:12px;padding:5px 12px;border-radius:20px;border:1px solid var(--line);background:#121826;color:var(--dim);display:flex;align-items:center;gap:6px}
.chip.on{color:#fff}
.chip .dot{width:9px;height:9px;border-radius:50%}
.chip .n{font:11px ui-monospace,monospace;opacity:.8}
.searchrow{margin-top:11px;display:flex;align-items:center;gap:10px}
#search{flex:1;max-width:440px;background:#0a0e16;border:1px solid #2b3650;color:#e7ecf3;border-radius:8px;padding:8px 12px;font-size:13px}
#search:focus{outline:none;border-color:#34f4ce}
#search-x{color:var(--dim);font-size:12px;cursor:pointer;display:none}
#search-x.show{display:inline}
.daterow{margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dim)}
.daterow input[type=date]{background:#0a0e16;border:1px solid #2b3650;color:#e7ecf3;border-radius:7px;padding:5px 9px;font-size:12px;color-scheme:dark}
.daterow input[type=date]:focus{outline:none;border-color:#34f4ce}
.pre{cursor:pointer;font-size:11.5px;padding:4px 10px;border-radius:14px;border:1px solid var(--line);background:#121826;color:#aeb8c8}
.pre:hover{border-color:#34f4ce;color:#fff}
.pre.on{background:#10302b;border-color:#34f4ce;color:#34f4ce}
main{max-width:1340px;margin:0 auto;padding:18px 28px 70px}
.cols{display:grid;grid-template-columns:var(--col-w,380px) 14px minmax(0,1fr);align-items:start}
.col-up{position:sticky;top:132px;max-height:calc(100vh - 150px);overflow:auto;padding-right:6px}
.col-feed{min-width:0}
.col-resize{align-self:stretch;min-height:340px;position:relative;cursor:col-resize;touch-action:none}
.col-resize::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:2px;transform:translateX(-50%);background:var(--line)}
.col-resize:hover::before,.col-resize.drag::before{background:#34f4ce;width:3px}
.col-resize::after{content:"⋮⋮";position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);color:var(--dim);font-size:11px;letter-spacing:-3px;writing-mode:vertical-rl}
.col-head{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);margin:6px 2px 0}
@media(max-width:900px){.cols{grid-template-columns:1fr}.col-up{position:static;max-height:none;overflow:visible}.col-resize{display:none}}
.day{margin:22px 0 8px;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);position:sticky;top:128px;background:#0b0e14;padding:4px 0;z-index:3}
.ev{display:grid;grid-template-columns:64px 92px 1fr;gap:12px;align-items:baseline;padding:8px 10px;border-radius:9px;border-left:3px solid var(--c,#2b3650);background:#0e1320;margin-bottom:5px}
.ev:hover{background:#121829}
.ev .tm{font:12px ui-monospace,monospace;color:#9fb0c9}
.ev .ty{font-size:9.5px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--c);align-self:center}
.ev .ti{font-weight:600}
.ev .dt{color:var(--dim);font-size:11.5px;font-weight:400}
.empty{color:var(--dim);padding:40px;text-align:center}
.pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:#34f4ce;margin-right:6px;animation:p 1.6s infinite}
@keyframes p{0%,100%{opacity:.3}50%{opacity:1}}
#upcoming{margin:16px 0 4px;border:1px solid #173a36;background:linear-gradient(180deg,rgba(52,244,206,.06),transparent);border-radius:12px;padding:13px 16px}
.up-head{font-size:13px;font-weight:700;color:#34f4ce;margin-bottom:9px}
.up-head .s{color:var(--dim);font-weight:400;font-size:11.5px}
.up-row{display:grid;grid-template-columns:92px 118px 1fr auto;gap:12px;align-items:baseline;padding:5px 4px;border-bottom:1px solid #122b28;font-size:12.5px}
.up-row .rel{color:#34f4ce;font:12px ui-monospace,monospace}
.up-row .abs{color:#9fb0c9;font:11.5px ui-monospace,monospace}
.up-row .lab{color:#e7ecf3;font-weight:600}
.up-row .cad{color:var(--dim);font-size:11px;white-space:nowrap}
.up-cont{margin-top:10px;font-size:11px;display:flex;gap:7px;flex-wrap:wrap;align-items:center}
.up-cont .cc{background:#0e1320;border:1px solid var(--line);border-radius:14px;padding:2px 9px;color:#aeb8c8}
.up-more{color:#34f4ce;font-size:11.5px;margin-top:8px;cursor:pointer}
.up-more:hover{text-decoration:underline}
.up-row,.cc,.ev{cursor:pointer}
.up-row:hover,.ev:hover{outline:1px solid #2b4a55}
.cc:hover{border-color:#34f4ce;color:#fff}
/* detail drawer */
#det{position:fixed;top:0;right:0;height:100vh;width:min(580px,95vw);background:#0d1119;border-left:1px solid #243049;box-shadow:-14px 0 50px rgba(0,0,0,.6);transform:translateX(102%);transition:transform .2s cubic-bezier(.4,0,.2,1);z-index:200;display:flex;flex-direction:column}
#det.open{transform:translateX(0)}
.det-head{padding:15px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
#det-title{font-size:15px;font-weight:700;flex:1;word-break:break-word}
.det-x{background:transparent;border:1px solid var(--line);color:var(--dim);border-radius:7px;width:30px;height:30px;cursor:pointer;font-size:15px}
.det-x:hover{color:#fff;border-color:#4b5b7a}
#det-body{padding:14px 18px;overflow:auto;flex:1;font-size:12.5px}
.det-k{color:#34f4ce;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;margin:14px 0 3px}
.det-pre{background:#0a0e16;border:1px solid var(--line);border-radius:8px;padding:11px;font:11.5px/1.5 ui-monospace,monospace;color:#c8d2e0;white-space:pre-wrap;word-break:break-word;margin:2px 0}
</style></head>
<body>
<header>
<h1>What it's doing <small>live activity timeline — agents, canaries, watch ticks, commits, spend</small></h1>
<div class="meta"><span class="pulse"></span><span id="meta">loading…</span> · auto-refreshes every 30s · <a href="/pyramid">← cabinet pyramid</a></div>
<div class="chips" id="chips"></div>
<div class="searchrow"><input id="search" type="search" placeholder="🔎 filter events & jobs — title, vendor, repo, skill…" autocomplete="off"><span id="search-x" onclick="clearSearch()">✕ clear</span></div>
<div class="daterow">📅 <input type="date" id="from"> <span>→</span> <input type="date" id="to">
<span class="pre" onclick="preset(0,this)">Today</span><span class="pre" onclick="preset(7,this)">7d</span><span class="pre" onclick="preset(30,this)">30d</span><span class="pre on" onclick="preset(null,this)">All</span>
<span class="pre csvbtn" onclick="exportCsv()" title="Download the currently-filtered events as CSV">⬇ Export CSV</span>
<span id="range-n"></span></div>
</header>
<div id="det"><div class="det-head"><span id="det-title">—</span><button class="det-x" onclick="closeDet()">✕</button></div><div id="det-body"></div></div>
<main class="cols">
<aside class="col-up"><section id="upcoming"></section></aside>
<div class="col-resize" id="splitter" title="Drag to resize · double-click to reset"></div>
<div class="col-feed"><div class="col-head">Recent activity</div><div id="feed"><div class="empty">Loading activity…</div></div></div>
</main>
<script>
const TYPES={
commit:{label:'Commits', col:'#a78bfa'},
canary:{label:'Canaries', col:'#34d399'},
win:{label:'Wins', col:'#fbbf24'},
cost:{label:'Spend', col:'#fb7185'},
tick:{label:'Watch ticks', col:'#60a5fa'},
};
const VCOL={FAIL:'#ef4444',WARN:'#fbbf24',PASS:'#34d399',NA:'#6b7280'};
let DATA=[], OFF_FILTER=new Set(), QUERY='', FROM=null, TO=null; // type/text/date filters
function matchQ(e){ return !QUERY || ((e.title||'')+' '+(e.detail||'')+' '+(e.type||'')).toLowerCase().includes(QUERY); }
function inRange(ts){ const t=Date.parse(ts); if(isNaN(t)) return true; return (FROM==null||t>=FROM)&&(TO==null||t<=TO); }
function colorFor(e){ if(e.type==='canary') return VCOL[e.verdict]||TYPES.canary.col; return (TYPES[e.type]||{}).col||'#2b3650'; }
function fmtTime(iso){ try{ return new Date(iso).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit'}); }catch(e){ return '--:--'; } }
function fmtDay(iso){ try{ return new Date(iso).toLocaleDateString(undefined,{weekday:'short',month:'short',day:'numeric',year:'numeric'}); }catch(e){ return iso; } }
function dayKey(iso){ try{ const d=new Date(iso); return d.getFullYear()+'-'+('0'+(d.getMonth()+1)).slice(-2)+'-'+('0'+d.getDate()).slice(-2); }catch(e){ return ''; } }
function renderChips(){
const set=DATA.filter(e=>matchQ(e)&&inRange(e.ts));
const counts={}; set.forEach(e=>counts[e.type]=(counts[e.type]||0)+1);
const c=document.getElementById('chips');
c.innerHTML='<span class="chip '+(OFF_FILTER.size?'':'on')+'" data-t="__all"><b>All</b> <span class="n">'+set.length+'</span></span>'+
Object.keys(TYPES).map(t=>`<span class="chip ${OFF_FILTER.has(t)?'':'on'}" data-t="${t}"><span class="dot" style="background:${TYPES[t].col}"></span>${TYPES[t].label} <span class="n">${counts[t]||0}</span></span>`).join('');
c.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>{
const t=ch.dataset.t;
if(t==='__all'){ OFF_FILTER.clear(); } else { OFF_FILTER.has(t)?OFF_FILTER.delete(t):OFF_FILTER.add(t); }
renderChips(); renderFeed();
});
}
function shownEvents(){ return DATA.filter(e=>!OFF_FILTER.has(e.type) && matchQ(e) && inRange(e.ts)); }
function csvCell(v){ v=(v==null?'':String(v)); return /[",\n]/.test(v)?'"'+v.replace(/"/g,'""')+'"':v; }
function exportCsv(){
const rows=shownEvents();
if(!rows.length){ document.getElementById('range-n').textContent='nothing to export'; return; }
const head=['timestamp_iso','datetime_local','type','verdict','title','detail','ref'];
const out=[head.join(',')];
rows.forEach(e=>{ let loc=''; try{ loc=new Date(e.ts).toLocaleString(); }catch(_){}
out.push([e.ts, loc, e.type, e.verdict||'', e.title||'', e.detail||'', e.repo?(e.repo+' '+e.hash):(e.skill||'')].map(csvCell).join(',')); });
const blob=new Blob([out.join('\n')],{type:'text/csv'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
a.download='activity-'+new Date().toISOString().replace(/[:T]/g,'-').slice(0,16)+'.csv'; a.click();
document.getElementById('range-n').textContent='exported '+rows.length+' rows';
}
function renderFeed(){
const shown=shownEvents();
const feed=document.getElementById('feed');
if(!shown.length){ feed.innerHTML='<div class="empty">'+(QUERY||FROM||TO?'No matches for the current filters.':'No events for this filter.')+'</div>'; return; }
let html='', curDay=null;
shown.forEach(e=>{
const dk=dayKey(e.ts);
if(dk!==curDay){ curDay=dk; html+=`<div class="day">${fmtDay(e.ts)}</div>`; }
const ty=e.type==='canary'?(e.verdict||'canary'):(TYPES[e.type]||{}).label||e.type;
html+=`<div class="ev" style="--c:${colorFor(e)}" data-type="${escA(e.type)}" data-ts="${escA(e.ts)}" data-title="${escA(e.title)}" data-detail="${escA(e.detail||'')}"${e.repo?` data-repo="${escA(e.repo)}" data-hash="${escA(e.hash)}"`:''}${e.skill?` data-skill="${escA(e.skill)}"`:''}><span class="tm">${fmtTime(e.ts)}</span><span class="ty">${ty}</span><span><span class="ti">${esc(e.title)}</span>${e.detail?` <span class="dt">— ${esc(e.detail)}</span>`:''}</span></div>`;
});
feed.innerHTML=html;
}
function esc(s){ return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
function escA(s){ return esc(s).replace(/"/g,'"'); }
async function load(){
try{
const r=await(await fetch('/api/activity')).json();
DATA=r.events||[];
document.getElementById('meta').textContent=DATA.length+' events · updated '+new Date(r.generated_at||Date.now()).toLocaleTimeString();
renderChips(); renderFeed();
}catch(e){ document.getElementById('feed').innerHTML='<div class="empty">Couldn’t reach the server (:9766).</div>'; }
}
function rel(ms){ const d=ms-Date.now(); if(d<=0) return 'now'; const m=Math.round(d/60000); if(m<60) return 'in '+m+'m'; const h=Math.floor(m/60); return 'in '+h+'h '+String(m%60).padStart(2,'0')+'m'; }
function fmtAbs(iso){ try{ return new Date(iso).toLocaleString(undefined,{weekday:'short',hour:'2-digit',minute:'2-digit'}); }catch(e){ return iso; } }
let SHOW_ALL_UP=false, SCHED={upcoming:[],continuous:[]};
async function loadSchedule(){
try{ const r=await(await fetch('/api/schedule')).json(); SCHED={upcoming:r.upcoming||[],continuous:r.continuous||[]}; }catch(e){}
renderSchedule();
}
function renderSchedule(){
let up=SCHED.upcoming, cont=SCHED.continuous;
if(QUERY){ up=up.filter(e=>((e.label||'')+' '+(e.cadence||'')).toLowerCase().includes(QUERY)); cont=cont.filter(c=>((c.label||'')+' '+(c.cadence||'')).toLowerCase().includes(QUERY)); }
const n=SHOW_ALL_UP?up.length:14;
const wf=up.filter(e=>e.wontFire).length;
let h='<div class="up-head">⏭ Upcoming <span class="s">— '+(up.length-wf)+' will fire'+(wf?` · <span style="color:#fb7185">${wf} won't (disabled/unloaded)</span>`:'')+'</span></div>';
h+=up.slice(0,n).map(e=>{
const flag = e.wontFire ? `<span class="rel" style="color:#fb7185" title="${esc(e.reason||'')}">⚠ won't fire</span>`
: (e.lastExit && e.lastExit!==0 ? `<span class="rel" style="color:#fbbf24" title="last exit ${e.lastExit}">${rel(Date.parse(e.next_ts))} ⚠</span>` : `<span class="rel">${rel(Date.parse(e.next_ts))}</span>`);
return `<div class="up-row" data-label="${escA(e.label)}" style="${e.wontFire?'opacity:.5':''}">${flag}<span class="abs">${e.wontFire?esc(e.reason||''):fmtAbs(e.next_ts)}</span><span class="lab">${esc(e.label)}</span><span class="cad">${esc(e.cadence)}</span></div>`;
}).join('');
if(up.length>14) h+=`<div class="up-more" onclick="SHOW_ALL_UP=!SHOW_ALL_UP;loadSchedule()">${SHOW_ALL_UP?'▲ show less':'▼ show all '+up.length}</div>`;
if(cont.length) h+='<div class="up-cont"><b style="color:#8a94a6">Continuous</b>'+cont.map(c=>`<span class="cc" data-label="${escA(c.label)}" style="${c.wontFire?'opacity:.5;border-color:#fb7185':''}" title="${c.wontFire?esc(c.reason||''):''}">${c.wontFire?'⚠ ':''}${esc(c.label)} · ${esc(c.cadence)}</span>`).join('')+'</div>';
document.getElementById('upcoming').innerHTML=h;
}
// ── click any process / event → detail drawer ──
function closeDet(){ document.getElementById('det').classList.remove('open'); }
function openDet(title, html){ document.getElementById('det-title').innerHTML=title; document.getElementById('det-body').innerHTML=html; document.getElementById('det').classList.add('open'); }
async function detJob(label){
openDet('⚙️ '+esc(label), '<div class="det-pre">loading…</div>');
let d; try{ d=await(await fetch('/api/job?label='+encodeURIComponent(label))).json(); }catch(e){ return openDet(esc(label),'<div class="det-pre">server unreachable</div>'); }
if(!d.ok) return openDet(esc(label), '<div class="det-pre">'+esc(d.error||'error')+'</div>');
const st=d.status||{};
const statusTxt = st.loaded ? ('loaded · '+(st.pid?('running PID '+st.pid):'idle')+(st.lastExit!=null?(' · last exit '+st.lastExit):'')) : 'not loaded';
let h='<div class="det-k">Schedule</div>'+esc(d.schedule)+
'<div class="det-k">Status</div>'+esc(statusTxt)+
'<div class="det-k">Command</div><div class="det-pre">'+esc((d.program||[]).join(' '))+'</div>';
if(d.stdout) h+='<div class="det-k">Log file</div><div class="det-pre">'+esc(d.stdout)+'</div>';
if(d.logTail) h+='<div class="det-k">Recent log (tail)</div><div class="det-pre">'+esc(d.logTail)+'</div>';
openDet('⚙️ '+esc(d.label), h);
}
async function detCommit(repo,hash){
openDet('🟣 '+esc(repo)+' · '+esc(hash), '<div class="det-pre">loading…</div>');
let d; try{ d=await(await fetch('/api/commit?repo='+encodeURIComponent(repo)+'&hash='+encodeURIComponent(hash))).json(); }catch(e){ return openDet('commit','<div class="det-pre">server unreachable</div>'); }
if(!d.ok) return openDet('commit','<div class="det-pre">'+esc(d.error)+'</div>');
openDet('🟣 '+esc(repo)+' · '+esc(hash), '<div class="det-pre">'+esc(d.detail)+'</div>');
}
async function detCanary(skill){
openDet('🩺 '+esc(skill), '<div class="det-pre">loading…</div>');
let d; try{ d=await(await fetch('/api/canary?skill='+encodeURIComponent(skill))).json(); }catch(e){ return openDet(esc(skill),'<div class="det-pre">server unreachable</div>'); }
if(!d.ok) return openDet(esc(skill),'<div class="det-pre">'+esc(d.error)+'</div>');
openDet('🩺 '+esc(skill), '<div class="det-pre">'+esc(JSON.stringify(d.data,null,2))+'</div>');
}
function detSimple(el){
let when=''; try{ when=new Date(el.dataset.ts).toLocaleString(); }catch(e){}
openDet(esc(el.dataset.title||''), '<div class="det-k">When</div>'+esc(when)+'<div class="det-k">'+esc(el.dataset.type||'')+'</div>'+esc(el.dataset.detail||'(no extra detail)'));
}
document.addEventListener('click', e=>{
if(e.target.closest('#det')||e.target.closest('#splitter')||e.target.closest('.chip')||e.target.closest('.up-more')) return;
const job=e.target.closest('[data-label]'); if(job){ detJob(job.dataset.label); return; }
const ev=e.target.closest('.ev'); if(ev){ const ty=ev.dataset.type;
if(ty==='commit'&&ev.dataset.repo) detCommit(ev.dataset.repo, ev.dataset.hash);
else if(ty==='canary'&&ev.dataset.skill) detCanary(ev.dataset.skill);
else detSimple(ev);
}
});
document.addEventListener('keydown', e=>{ if(e.key==='Escape') closeDet(); });
// draggable splitter — resize the two columns (persisted; double-click resets)
(function(){
const cols=document.querySelector('.cols'), sp=document.getElementById('splitter'), KEY='cabinet-activity-colw';
const MIN=260, MAX=720;
const saved=parseInt(localStorage.getItem(KEY)||'',10);
if(saved>=MIN && saved<=MAX) cols.style.setProperty('--col-w', saved+'px');
let dragging=false;
function move(x){ const r=cols.getBoundingClientRect(); let w=Math.max(MIN,Math.min(MAX, x-r.left)); cols.style.setProperty('--col-w', w+'px'); return w; }
sp.addEventListener('mousedown', e=>{ dragging=true; sp.classList.add('drag'); document.body.style.userSelect='none'; e.preventDefault(); });
window.addEventListener('mousemove', e=>{ if(dragging) move(e.clientX); });
window.addEventListener('mouseup', ()=>{ if(!dragging) return; dragging=false; sp.classList.remove('drag'); document.body.style.userSelect=''; const w=parseInt(cols.style.getPropertyValue('--col-w'),10); if(w) localStorage.setItem(KEY,w); });
sp.addEventListener('dblclick', ()=>{ cols.style.setProperty('--col-w','380px'); localStorage.setItem(KEY,'380'); });
})();
// date range filter
function pad2(n){ return ('0'+n).slice(-2); }
function fmtDI(d){ return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate()); }
function recomputeDates(){
const fv=document.getElementById('from').value, tv=document.getElementById('to').value;
FROM = fv ? Date.parse(fv+'T00:00:00') : null;
TO = tv ? Date.parse(tv+'T23:59:59') : null;
const set=DATA.filter(e=>matchQ(e)&&inRange(e.ts));
document.getElementById('range-n').textContent = (FROM||TO) ? (set.length+' in range') : '';
renderChips(); renderFeed();
}
function preset(days, el){
const f=document.getElementById('from'), t=document.getElementById('to');
if(days==null){ f.value=''; t.value=''; }
else { const now=new Date(); t.value=fmtDI(now); f.value=fmtDI(new Date(now.getTime()-days*86400000)); }
document.querySelectorAll('.pre').forEach(p=>p.classList.remove('on'));
if(el) el.classList.add('on');
recomputeDates();
}
['from','to'].forEach(id=>document.getElementById(id).addEventListener('change', ()=>{ document.querySelectorAll('.pre').forEach(p=>p.classList.remove('on')); recomputeDates(); }));
const _si=document.getElementById('search');
_si.addEventListener('input', ()=>{ QUERY=_si.value.trim().toLowerCase(); document.getElementById('search-x').classList.toggle('show', !!QUERY); renderChips(); renderFeed(); renderSchedule(); });
function clearSearch(){ _si.value=''; QUERY=''; document.getElementById('search-x').classList.remove('show'); _si.focus(); renderChips(); renderFeed(); renderSchedule(); }
load(); loadSchedule(); setInterval(()=>{ load(); loadSchedule(); }, 30000);
</script>
</body></html>