vitals/index.html

630 lines
28 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vitals — workload map</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/icons/icon-180.png">
<meta property="og:image" content="/icons/icon-512.png">
<meta property="og:title" content="Vitals — workload map">
<meta property="og:description" content="Group processes by human identity, then cross the browser boundary and give every tab its own CPU number.">
<meta name="twitter:card" content="summary_large_image">
<style>
:root{
--bg:#0d1013; --panel:#12161a; --panel2:#171c21; --line:#232a31;
--fg:#e6ebef; --dim:#8b98a5; --dim2:#5f6b76;
--hot:#e0603f; --warn:#d9a13b; --ok:#3f9e86; --cool:#4f8fb5;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);
font:13px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-font-smoothing:antialiased}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
.wrap{max-width:1180px;margin:0 auto;padding:22px 26px 60px}
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:18px}
.brand{display:flex;align-items:baseline;gap:10px}
.brand b{font-size:16px;letter-spacing:.14em;font-weight:600}
.brand span{color:var(--dim2);font-size:11px;letter-spacing:.04em}
.meta{color:var(--dim2);font-size:11px;text-align:right}
h2{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);
font-weight:500;margin:26px 0 8px}
/* hero */
.hero{display:flex;align-items:flex-end;gap:28px;flex-wrap:wrap}
.stat b{font-size:34px;font-weight:650;letter-spacing:-.02em;line-height:1}
.stat b small{font-size:15px;font-weight:500;color:var(--dim);letter-spacing:0}
.stat i{display:block;font-style:normal;color:var(--dim2);font-size:10.5px;
letter-spacing:.08em;text-transform:uppercase;margin-top:5px}
.machine{display:flex;height:34px;border-radius:5px;overflow:hidden;margin-top:16px;
border:1px solid var(--line);background:#0a0d10}
.machine div{height:100%;transition:width .35s ease}
.legend{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:11px;color:var(--dim)}
.legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;vertical-align:0}
/* flags */
.flags{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
.flag{font-size:11px;padding:3px 9px;border-radius:3px;border:1px solid var(--line);
background:var(--panel);color:var(--dim)}
.flag.hot{border-color:#5c2f24;background:#241512;color:#f0a58c}
.flag.warn{border-color:#5a4520;background:#221c10;color:#e8c88a}
/* table */
.tabhead,.row{display:grid;grid-template-columns:minmax(220px,1.5fr) 92px 74px 66px 62px 78px 74px 22px;
align-items:center;gap:10px;padding:0 10px}
.tabhead{color:var(--dim2);font-size:10px;letter-spacing:.09em;text-transform:uppercase;
border-bottom:1px solid var(--line);padding-bottom:7px;margin-bottom:2px}
.tabhead div:nth-child(n+3){text-align:right}
.tabhead div:nth-child(2){text-align:left}
.rows{border-bottom:1px solid var(--line)}
.row{padding:9px 10px;border-bottom:1px solid #1a2027;cursor:pointer;position:relative}
.row:hover{background:var(--panel)}
.row.open{background:var(--panel2)}
.row .nm{display:flex;align-items:center;gap:9px;min-width:0}
.row .nm .dot{width:9px;height:9px;border-radius:2px;flex:0 0 9px}
.row .nm .txt{min-width:0}
.row .nm .t1{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .nm .t2{color:var(--dim2);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .bar{height:8px;background:#1b2229;border-radius:2px;overflow:hidden}
.row .bar i{display:block;height:100%;border-radius:2px}
.num{text-align:right;color:var(--fg);font-size:12px}
.num.dim{color:var(--dim)}
.chev{color:var(--dim2);font-size:11px;text-align:right}
.spark{width:92px;height:22px}
.chip{font-size:9.5px;padding:1px 5px;border-radius:2px;margin-left:6px;
border:1px solid #5c2f24;background:#241512;color:#f0a58c;white-space:nowrap}
/* detail */
.detail{padding:6px 10px 16px 30px;background:var(--panel2);border-bottom:1px solid #1a2027}
.dgrid{display:grid;grid-template-columns:1fr 300px;gap:22px}
.dlist .ph{display:grid;grid-template-columns:1fr 88px 62px 58px 62px;gap:8px;
font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim2);
border-bottom:1px solid var(--line);padding-bottom:5px;margin-bottom:4px}
.dlist .ph div:nth-child(n+2){text-align:right}
.p{display:grid;grid-template-columns:1fr 88px 62px 58px 62px;gap:8px;padding:5px 0;
border-bottom:1px solid #1a2027;align-items:baseline}
.p .r{color:var(--dim);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.p .r b{color:var(--fg);font-weight:500}
.p .r em{font-style:normal;color:var(--dim2);font-size:10px;margin-left:6px}
.p .why{grid-column:1/-1;color:var(--dim2);font-size:10.5px;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chart{border:1px solid var(--line);border-radius:4px;padding:8px;background:#0b0e11}
.chart .cap{font-size:10px;color:var(--dim2);letter-spacing:.06em;text-transform:uppercase;margin-bottom:4px}
.empty{color:var(--dim2);font-size:12px;padding:14px 0}
.pt{display:grid;grid-template-columns:1fr 70px 66px;gap:8px;padding:5px 0;
border-bottom:1px solid #1a2027;align-items:baseline}
.pt .r{color:var(--dim);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pt .r b{color:var(--fg);font-weight:500}
.pt .r em{font-style:normal;color:var(--dim2);font-size:10px;margin-left:7px}
.verdict{margin-top:8px;padding:7px 10px;border-left:2px solid var(--warn);
background:#1c1710;color:#d8bb84;font-size:11px;line-height:1.4}
.banner{margin:16px 0 0;padding:11px 13px;border:1px solid #4a3d24;background:#191510;
border-radius:4px;font-size:11.5px;color:#c9b58c}
.banner b{color:#e8c88a;font-weight:600}
.banner code{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:#e6ebef;
background:#0b0e11;padding:2px 5px;border-radius:2px;display:inline-block;margin-top:5px}
.tabs .th,.tabs .tr{display:grid;grid-template-columns:1fr 120px 70px 64px;gap:10px;
padding:6px 10px;align-items:baseline}
.tabs .th{font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim2);
border-bottom:1px solid var(--line);padding-bottom:5px}
.tabs .th div:nth-child(n+2){text-align:right}
.tabs .tr{border-bottom:1px solid #1a2027}
.tabs .tr .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tabs .tr .t em{font-style:normal;display:block;color:var(--dim2);font-size:10px;
font-family:ui-monospace,Menlo,monospace}
.tabs .tr .b{color:var(--dim);font-size:11px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.controls{display:flex;gap:10px;align-items:center;margin:8px 0 0}
input[type=search]{background:var(--panel);border:1px solid var(--line);color:var(--fg);
border-radius:3px;padding:5px 9px;font-size:12px;width:230px;outline:none}
input[type=search]:focus{border-color:#37424d}
button{background:var(--panel);border:1px solid var(--line);color:var(--dim);border-radius:3px;
padding:4px 9px;font-size:11px;cursor:pointer;font-family:inherit}
button:hover{border-color:#37424d;color:var(--fg)}
/* --- live-update stability: nothing moves unless its own value moved --- */
.stat b{font-variant-numeric:tabular-nums}
.stat .v{display:inline-block;text-align:right}
.stat.busy .v{min-width:2.2ch}
.stat.cores .v{min-width:4.4ch}
.machine div{transition:width .9s linear}
.legend span{white-space:nowrap;font-variant-numeric:tabular-nums}
.legend span em{font-style:normal;display:inline-block;min-width:3.1em;text-align:right}
.rows{display:flex;flex-direction:column}
.row .bar i{transition:width .9s linear}
.num{font-variant-numeric:tabular-nums}
.stat, .legend span, .tabs .tr{will-change:auto}
/* site identity in the tab list */
.tabs .tr .t{display:flex;gap:8px;align-items:center;min-width:0}
.tabs .tr .tt{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tabs .tr .tt b{font-weight:500}
.ico{width:16px;height:16px;flex:0 0 16px;border-radius:3px;display:inline-flex;
align-items:center;justify-content:center;font-size:9px;font-weight:700;color:#0d1013;
overflow:hidden;background:#2a333c}
.ico img{width:16px;height:16px;display:block}
.badge{font-size:9.5px;border:1px solid var(--line);border-radius:2px;padding:0 4px;
color:var(--dim);margin-left:6px;white-space:nowrap;vertical-align:1px}
.badge.self{border-color:#3f5c52;color:#8fd0bf}
/* role groups in the detail pane */
.role{padding:4px 0 9px;border-bottom:1px solid #1a2027}
.role .rh{display:grid;grid-template-columns:1fr 64px 76px 78px;gap:8px;align-items:baseline}
.role .rh .rn b{font-weight:500;font-size:11.5px}
.role .rh .cnt{color:var(--dim2);font-size:10px;margin-left:6px;border:1px solid var(--line);
padding:0 4px;border-radius:2px;font-variant-numeric:tabular-nums}
.role .gloss{color:var(--dim);font-size:11px;margin-top:3px;padding-right:8px}
.role .hint{color:#e8c88a;font-size:11px;margin-top:3px}
.role .pids{color:var(--dim2);font-size:9.5px;margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
.ph.roles{grid-template-columns:1fr 64px 76px 78px}
details.procs{margin-top:10px}
details.procs summary{cursor:pointer;color:var(--dim2);font-size:10px;letter-spacing:.07em;
text-transform:uppercase;padding:4px 0}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand"><b>VITALS</b><span id="hostlabel">workload map</span></div>
<div class="meta mono" id="meta">connecting…</div>
</header>
<div class="hero">
<div class="stat busy"><b id="busy"><span class="v">–</span><small>%</small></b><i>of machine busy</i></div>
<div class="stat cores"><b id="cores"><span class="v">–</span><small id="coresmax"> / –</small></b><i>cores in use</i></div>
<div class="stat"><b id="topname" style="font-size:22px">–</b><i id="topsub">largest workload</i></div>
</div>
<div class="machine" id="machine"></div>
<div class="legend" id="legend"></div>
<div class="flags" id="flags"></div>
<h2>Workloads</h2>
<div class="controls">
<input type="search" id="q" placeholder="filter workloads…">
<button id="sortbtn">sorted by CPU</button>
<button id="idlebtn">show idle</button>
</div>
<div style="margin-top:14px">
<div class="tabhead">
<div>workload</div><div></div><div>% machine</div><div>cores</div>
<div>RAM</div><div>age</div><div>trend</div><div></div>
</div>
<div class="rows" id="rows"></div>
</div>
<h2>Browser tabs</h2>
<div id="banner"></div>
<div class="tabs">
<div class="th"><div>tab</div><div>browser</div><div>page work</div><div>heap</div></div>
<div id="tabs"></div>
</div>
<div style="margin-top:14px;color:var(--dim2);font-size:10.5px">
% machine = share of all cores (matches GNOME System Monitor). cores = same load as top/ps report it.
Page work = main-thread task time inside that tab, from the browser's own CDP counters.
Trend = last 5 minutes. This monitor costs <span class="mono" id="selfcost">–</span>.
</div>
</div>
<script>
const $ = s => document.querySelector(s);
const PALETTE = ["#e0603f","#4f8fb5","#3f9e86","#c98a2e","#8a6fb0","#b4652a",
"#5a8f3f","#b03a48","#3f7f9e","#9e7f3f","#6f7f8f","#8f3f6f"];
const colorCache = {};
function colorFor(key){
if(colorCache[key]) return colorCache[key];
let h=0; for(const c of key) h=(h*31+c.charCodeAt(0))>>>0;
return colorCache[key]=PALETTE[h%PALETTE.length];
}
const state = {snap:null, open:new Set(), details:{}, sort:"cpu", hideIdle:true, q:"",
rows:new Map(), dets:new Map(), segs:new Map(), leg:new Map(),
rank:new Map(), low:new Map(), lastT:0, lastTabs:"", lastBanner:""};
const ease = {busy:0, busyT:0, cores:0, coresT:0};
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const elBusy = $("#busy .v"), elCores = $("#cores .v");
/* write only when the value actually changed */
function setText(el,txt){ if(el && el.__t!==txt){ el.textContent=txt; el.__t=txt; } }
function setHTML(el,html){ if(el && el.__h!==html){ el.innerHTML=html; el.__h=html; } }
function setW(el,pct){ const v=pct.toFixed(2)+"%"; if(el && el.__w!==v){ el.style.width=v; el.__w=v; } }
function keyed(parent,map,key,tag,cls){
let el=map.get(key);
if(!el){ el=document.createElement(tag); if(cls) el.className=cls; parent.appendChild(el); map.set(key,el); }
return el;
}
function prune(map,keep){ for(const [k,el] of map){ if(!keep.has(k)){ el.remove(); map.delete(k); } } }
/* one rAF loop eases the hero numbers toward the newest sample instead of snapping */
function loop(){
const step=(cur,tgt)=> {
const d=tgt-cur;
if(REDUCED || Math.abs(d)<0.02) return tgt;
return cur + d*0.16;
};
ease.busy = step(ease.busy, ease.busyT);
ease.cores = step(ease.cores, ease.coresT);
setText(elBusy, String(Math.round(ease.busy)));
setText(elCores, ease.cores.toFixed(2));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
function fmtAge(h){
if(h<1) return Math.round(h*60)+"m";
if(h<48) return h.toFixed(1)+"h";
return (h/24).toFixed(1)+"d";
}
function sparkPath(series){
const w=92,h=22;
if(!series||series.length<2) return "";
const max=Math.max(...series,0.1);
return series.map((v,i)=>(i?"L":"M")+(i/(series.length-1)*w).toFixed(1)+" "+
(h-2-(v/max)*(h-4)).toFixed(1)).join(" ");
}
function flagChips(w){
return (w.flags||[]).map(f=>`<span class="chip">${f}</span>`).join("");
}
function render(){
const s=state.snap; if(!s) return;
const cores=s.cores;
setText($("#meta"), `live · ${new Date(s.t*1000).toLocaleTimeString()} · ${s.sample_ms}ms · this monitor ${s.self_cost.toFixed(2)} cores`);
if(s.host) setText($("#hostlabel"), "workload map · " + s.host);
ease.busyT = s.total_pct; ease.coresT = s.total_busy;
setText($("#coresmax"), " / " + cores);
const top=s.workloads[0];
setText($("#topname"), top? top.name : "—");
setText($("#topsub"), top ? `largest workload · ${top.cpu.toFixed(2)} cores` : "largest workload");
setText($("#selfcost"), `${s.self_cost.toFixed(2)} cores`);
// machine bar: top 8 workloads + rest. Segments are keyed and reordered by
// flex order, so widths actually animate instead of being recreated each tick.
const top8=s.workloads.slice(0,8);
const used=top8.reduce((a,w)=>a+w.cpu,0);
const rest=Math.max(0, s.total_busy-used);
const segs=[];
top8.forEach((w,i)=>segs.push({key:w.key,name:w.name,cpu:w.cpu,color:colorFor(w.key)}));
if(rest>0.02) segs.push({key:"__rest",name:"rest",cpu:rest,color:"#3a434c"});
const keepSeg=new Set();
segs.forEach((sg,i)=>{
keepSeg.add(sg.key);
const el=keyed($("#machine"),state.segs,sg.key,"div");
setW(el, Math.min(100, sg.cpu/cores*100));
if(el.__bg!==sg.color){ el.style.background=sg.color; el.__bg=sg.color; }
el.style.order=i;
const lg=keyed($("#legend"),state.leg,sg.key,"span");
lg.style.order=i;
const sig=sg.name+"|"+sg.cpu.toFixed(2)+"|"+sg.color;
if(lg.__s!==sig){
lg.innerHTML=`<i style="background:${sg.color}"></i>${esc(sg.name)} <em>${sg.cpu.toFixed(2)}</em>`;
lg.__s=sig;
}
});
prune(state.segs,keepSeg); prune(state.leg,keepSeg);
setHTML($("#flags"), (s.flags||[]).map(f=>{
const cls = f.flag==="leaked" ? "hot" : "warn";
return `<span class="flag ${cls}">${f.name}: ${f.flag}${f.flag==="leaked"?` · ${f.cpu.toFixed(2)} cores`:""}</span>`;
}).join(""));
$("#flags").style.display = (s.flags||[]).length ? "flex" : "none";
// browser tabs: one row per site, with the site's own icon. Rebuilt only
// when the data changes, so icons are not re-fetched every tick.
const tabSig=JSON.stringify(s.tabs||[]);
if(state.lastTabs!==tabSig){
state.lastTabs=tabSig;
const host=$("#tabs");
host.innerHTML="";
const tabs=(s.tabs||[]).slice().sort((a,b)=>(b.task_pct||0)-(a.task_pct||0));
if(!tabs.length){
const e=document.createElement("div"); e.className="empty";
e.textContent = s.brave_cdp ? "no tabs reported yet" : "no tab data yet";
host.appendChild(e);
}
tabs.forEach(t=>host.appendChild(tabRow(t, s.self_url)));
}
const brave = (s.workloads||[]).find(w=>/brave/i.test(w.name) && w.cpu > 0.05);
const banner = (!s.brave_cdp && brave) ? `<div class="banner">
<b>Brave is not speaking CDP, so its tabs are invisible.</b> Restart it once with the debug port and
every tab shows up here with its own page-work number, title, and URL. Tabs restore from session.
<br><code>flatpak kill com.brave.Browser && flatpak run com.brave.Browser --remote-debugging-port=9222</code>
</div>` : "";
if(state.lastBanner!==banner){ state.lastBanner=banner; $("#banner").innerHTML=banner; }
// rows: keyed DOM, hysteresis on appearance, quantised sort so rows do not
// swap places over a hundredth of a core
let list=s.workloads.slice();
const q=state.q.toLowerCase();
if(q) list=list.filter(w=>(w.name+" "+w.key).toLowerCase().includes(q));
const visible=[];
for(const w of list){
if(!state.hideIdle){ state.low.delete(w.key); visible.push(w); continue; }
if(w.cpu>=0.05){ state.low.delete(w.key); visible.push(w); continue; }
// already on screen and still above 0.02: keep it for two more samples
const n=(state.low.get(w.key)||0);
if(state.rows.has(w.key) && w.cpu>=0.02 && n<2){ state.low.set(w.key,n+1); visible.push(w); }
}
const by=state.sort;
const qz=x=>Math.round(x*10)/10;
const prevRank=state.rank;
visible.sort((a,b)=>{
let d;
if(by==="cpu") d=qz(b.cpu)-qz(a.cpu);
else if(by==="ram") d=qz(b.rss_mb/100)-qz(a.rss_mb/100);
else d=qz(b.age_h*10)-qz(a.age_h*10);
if(Math.abs(d)>0.0001) return d;
return (prevRank.get(a.key)??1e9)-(prevRank.get(b.key)??1e9);
});
state.rank=new Map(visible.map((w,i)=>[w.key,i]));
const maxCpu=Math.max(0.2,...visible.map(w=>w.cpu));
const host=$("#rows");
const keepRows=new Set();
visible.forEach((w,i)=>{
keepRows.add(w.key);
let el=state.rows.get(w.key);
if(!el){ el=buildRow(w.key); host.appendChild(el); state.rows.set(w.key,el); }
el.style.order = i*2;
updateRow(el,w,maxCpu);
if(state.open.has(w.key)){
let det=state.dets.get(w.key);
if(!det){ det=document.createElement("div"); det.className="detail";
det.dataset.detail=w.key; host.appendChild(det); state.dets.set(w.key,det); }
det.style.order = i*2+1;
updateDetail(det,w,state.details[w.key]);
}
});
for(const [k,el] of state.rows){
if(!keepRows.has(k)){ el.remove(); state.rows.delete(k); state.rank.delete(k); }
}
for(const [k,el] of state.dets){
if(!keepRows.has(k) || !state.open.has(k)){ el.remove(); state.dets.delete(k); }
}
let empty=host.querySelector(".empty");
if(!visible.length){
if(!empty){ empty=document.createElement("div"); empty.className="empty"; host.appendChild(empty); }
empty.textContent = q ? "nothing matches" : "nothing above the idle line";
} else if(empty){ empty.remove(); }
}
function buildRow(key){
const el=document.createElement("div");
el.className="row"; el.dataset.key=key;
el.innerHTML=`<div class="nm"><span class="dot"></span>
<div class="txt"><div class="t1"></div><div class="t2"></div></div></div>
<div class="bar"><i></i></div>
<div class="num pct"></div>
<div class="num mono ccores"></div>
<div class="num dim ram"></div>
<div class="num dim age"></div>
<div class="sp"><svg class="spark" viewBox="0 0 92 22" preserveAspectRatio="none">
<path fill="none" stroke="#666" stroke-width="1.4" stroke-linejoin="round"/></svg></div>
<div class="chev">▼</div>`;
el.onclick=()=>{
const k=el.dataset.key;
if(state.open.has(k)) state.open.delete(k);
else { state.open.add(k); loadDetail(k); }
render();
};
return el;
}
function updateRow(el,w,maxCpu){
const c=colorFor(w.key);
const dot=el.querySelector(".dot");
if(dot.__c!==c){ dot.style.background=c; dot.__c=c; }
const t1=el.querySelector(".t1");
const sig=w.name+"|"+(w.flags||[]).join(",");
if(t1.__s!==sig){ t1.innerHTML=esc(w.name)+flagChips(w); t1.__s=sig; }
setText(el.querySelector(".t2"), meta2(w));
const bar=el.querySelector(".bar i");
if(bar.__c!==c){ bar.style.background=c; bar.__c=c; }
setW(bar, Math.max(0,Math.min(100, w.cpu/maxCpu*100)));
setText(el.querySelector(".pct"), w.pct.toFixed(1)+"%");
setText(el.querySelector(".ccores"), w.cpu.toFixed(2));
setText(el.querySelector(".ram"), fmtRam(w.rss_mb));
setText(el.querySelector(".age"), fmtAge(w.age_h));
const d=state.details[w.key];
const path=el.querySelector(".sp path");
const pd=sparkPath(w.series || (d && d.series));
if(path.__d!==pd){ path.setAttribute("d", pd); path.__d=pd; }
if(path.__c!==c){ path.setAttribute("stroke", c); path.__c=c; }
const open=state.open.has(w.key);
if(el.__open!==open){
el.classList.toggle("open", open);
el.querySelector(".chev").textContent = open ? "▲" : "▼";
el.__open=open;
}
}
function updateDetail(det,w,d){
const sig=(d? JSON.stringify(d) : "pending")+w.name;
if(det.__s===sig) return;
det.__s=sig;
det.innerHTML=detailHTML(w,d);
}
function meta2(w){
const parts=[];
if(w.roles){ const top=Object.entries(w.roles).sort((a,b)=>b[1]-a[1]).slice(0,3)
.map(([r,v])=>`${r} ${v.toFixed(2)}`).join(" · ");
if(top) parts.push(top); }
if(w.instances>1) parts.push(`${w.instances} instances`);
parts.push(`${w.procs} procs`);
return parts.join(" · ");
}
function fmtRam(mb){ return mb>=1024 ? (mb/1024).toFixed(2)+" GB" : mb+" MB"; }
function detailHTML(w,d){
if(!d) return `<div class="empty">loading…</div>`;
const tabBlock = (w.tabs&&w.tabs.length) ? `
<div style="margin-bottom:14px">
<div class="ph" style="grid-template-columns:1fr 84px 66px;"><div>site</div><div>page work</div><div>heap</div></div>
${w.tabs.map(t=>`<div class="pt">
<div class="r"><b>${esc(hostOf(t.url) || t.title)}</b><em>${esc(t.title)}${(t.tab_count||1)>1?` · ${t.tab_count} tabs`:""}</em></div>
<div class="num mono">${(t.task_pct||0).toFixed(1)}%</div>
<div class="num dim">${t.heap_mb||0} MB</div></div>`).join("")}
${w.verdict?`<div class="verdict">${esc(w.verdict)}</div>`:""}
</div>` : "";
const roles = d.roles || [];
const roleBlock = roles.length ? `
<div class="ph roles"><div>role</div><div>cores</div><div>RAM</div><div>age</div></div>
${roles.map(r=>`
<div class="role">
<div class="rh">
<div class="rn"><b>${esc(r.role)}</b>${r.count>1?`<span class="cnt">×${r.count}</span>`:""}</div>
<div class="num mono">${(r.cpu||0).toFixed(2)}</div>
<div class="num dim">${fmtRam(r.rss_mb||0)}</div>
<div class="num dim">${r.count>1 ? fmtAge(r.age_max)+" max" : fmtAge(r.age_max)}</div>
</div>
${r.gloss?`<div class="gloss">${esc(r.gloss)}</div>`:""}
${r.hint?`<div class="hint">${esc(r.hint)}</div>`:""}
<div class="pids mono">pid ${r.pids.slice(0,12).join(" · ")}${r.pids.length>12?" …":""}</div>
</div>`).join("")}` : "";
const procRows = (d.processes||[]).map(p=>{
const why=[];
if(p.windows.length) why.push("window: "+p.windows[0]);
if(p.ports.length) why.push("ports "+p.ports.join(","));
const cg=p.cgroup? p.cgroup.split("/").slice(-1)[0] : "";
if(cg) why.push(cg);
return `<div class="p">
<div class="r"><b>${p.role}</b><em>pid ${p.pid} · ${p.rss_mb} MB</em></div>
<div class="num mono">${(p.cpu||0).toFixed(2)}</div>
<div class="num dim">${fmtAge((p.age_h||0))}</div>
<div class="num dim">${(p.threads&&p.threads[0])?p.threads[0].name.slice(0,10):"—"}</div>
<div class="num dim">${p.threads&&p.threads[0]?p.threads[0].ticks>0?Math.round(p.threads[0].ticks/100)+"s":"":""}</div>
<div class="why">${esc(why.join(" · "))}</div>
</div>`;
}).join("");
const procTable = procRows ? `
<details class="procs"><summary>${d.processes.length} individual processes</summary>
<div class="dlist">
<div class="ph"><div>process / role</div><div>cores</div><div>age</div><div>hot thread</div><div>cpu</div></div>
${procRows}
</div></details>` : "";
return tabBlock + `<div class="dgrid"><div class="dlist">
${roleBlock || `<div class="empty">no processes seen in this sample</div>`}
${procTable}</div>
<div class="chart"><div class="cap">last 5 minutes · cores</div>
${bigChart(d.series, colorFor(w.key))}</div></div>`;
}
function esc(s){ return (s||"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function shortUrl(u){
try{ const x=new URL(u); return x.hostname.replace(/^www\./,"")+x.pathname.slice(0,40); }
catch(e){ return (u||"").slice(0,50); }
}
function hostOf(u){
try{ return new URL(u).hostname.replace(/^www\./,""); }catch(e){ return ""; }
}
/* The icon comes from the site itself through the local server, cached on disk.
If the site has none, a letter tile in the site's own colour stands in. */
function iconEl(url, host){
const box=document.createElement("span");
box.className="ico";
const img=document.createElement("img");
img.alt=""; img.loading="lazy"; img.decoding="async";
img.src="/api/icon?u="+encodeURIComponent(url||"");
img.onerror=()=>{
img.remove();
box.textContent=(host||"?").slice(0,1).toUpperCase();
box.style.background=colorFor(host||"?");
};
box.appendChild(img);
return box;
}
function tabRow(t, selfUrl){
const row=document.createElement("div"); row.className="tr";
const host=hostOf(t.url);
const cell=document.createElement("div"); cell.className="t";
cell.appendChild(iconEl(t.url, host));
const txt=document.createElement("div"); txt.className="tt";
const name=document.createElement("b"); name.textContent=host || t.title || t.url || "(untitled)";
txt.appendChild(name);
if((t.tab_count||1)>1){
const b=document.createElement("span"); b.className="badge"; b.textContent="×"+t.tab_count; txt.appendChild(b);
}
if(selfUrl && (t.url||"").startsWith(selfUrl)){
const b=document.createElement("span"); b.className="badge self"; b.textContent="this screen"; txt.appendChild(b);
}
const em=document.createElement("em"); em.textContent=t.title || ""; txt.appendChild(em);
cell.appendChild(txt); row.appendChild(cell);
const bcell=document.createElement("div"); bcell.className="b";
bcell.textContent = t.port ? "instance "+t.port : (t.browser||"");
row.appendChild(bcell);
const pct=document.createElement("div"); pct.className="num mono";
pct.textContent=((t.task_pct||0)).toFixed(1)+"%"; row.appendChild(pct);
const heap=document.createElement("div"); heap.className="num dim";
heap.textContent=((t.heap_mb||0))+" MB"; row.appendChild(heap);
return row;
}
function bigChart(series,color){
const w=284,h=120;
if(!series||series.length<2) return `<div class="empty">collecting…</div>`;
const max=Math.max(...series,0.2);
const pts=series.map((v,i)=>[i/(series.length-1)*w, h-4-(v/max)*(h-12)]);
const d=pts.map((p,i)=>(i?"L":"M")+p[0].toFixed(1)+" "+p[1].toFixed(1)).join(" ");
const area=`${d} L${w} ${h} L0 ${h} Z`;
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:${h}px">
<path d="${area}" fill="${color}22" stroke="none"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5"/>
<text x="2" y="10" fill="#5f6b76" font-size="9">${max.toFixed(2)}</text>
<text x="2" y="${h-2}" fill="#5f6b76" font-size="9">0</text>
</svg>`;
}
async function loadDetail(key){
try{
const r=await fetch("/api/detail?key="+encodeURIComponent(key));
const d=await r.json();
state.details[key]=d;
render();
}catch(e){}
}
async function tick(){
try{
const r=await fetch("/api/snapshot",{cache:"no-store"});
const s=await r.json();
// The sampler produces a frame every 2s. Poll faster than that so a new
// frame is picked up within ~0.7s, and ignore repeats so the DOM is never
// rewritten with numbers the user already sees.
if(s.t===state.lastT) return;
state.lastT=s.t;
state.snap=s;
render();
for(const k of state.open) if(state.details[k]) loadDetail(k);
}catch(e){ setText($("#meta"),"server unreachable"); }
}
$("#q").oninput=e=>{state.q=e.target.value;render();};
$("#sortbtn").onclick=e=>{
state.sort = state.sort==="cpu"?"ram":"cpu";
e.target.textContent = state.sort==="cpu"?"sorted by CPU":"sorted by RAM";
render();
};
$("#idlebtn").onclick=e=>{
state.hideIdle=!state.hideIdle;
e.target.textContent = state.hideIdle?"show idle":"hide idle";
render();
};
tick();
setInterval(tick, 700);
</script>
</body>
</html>