vitals/index.html
CLU 95c1c1d3eb Live UI: stop the page from jumping, and put Vitals in the bookmark bar
The page rebuilt its whole header, bar and row list twice a second, so
numbers snapped, columns resized as digits changed, and rows swapped places
over a hundredth of a core.

- header values are tabular-width and tweened toward the newest sample
- machine-bar segments are keyed and reordered by flex order, so widths
  animate instead of being recreated mid-transition
- rows are keyed DOM nodes updated in place, sorted on a 0.1-core grid with
  the previous order as tiebreak, with hysteresis at the idle threshold
- tabs, banner and detail panes only re-render when their data changes
- poll every 700ms and drop repeat frames from the 2s sampler

bookmark.py puts a Vitals bookmark and a popup bookmarklet in the browser's
bookmark bar. It recomputes Chromium's bookmark checksum (MD5 over the
canonical node serialisation) and verifies it after writing, because a file
that fails that check leaves the browser showing an empty bar while the file
on disk still looks perfect. Refuses while the browser holds the profile,
keeps a timestamped backup, idempotent. install.sh runs it.

Co-developed-with: CLU
2026-10-20 13:52:49 -05:00

525 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
<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}
</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 sample`);
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: rebuilt only when the tab data itself changes
const tabSig=JSON.stringify(s.tabs||[]);
if(state.lastTabs!==tabSig){
state.lastTabs=tabSig;
const tabRows = (s.tabs||[]).map(t=>{
const meta = [shortUrl(t.url)];
if(t.port) meta.push("port "+t.port);
if(t.proc_cores) meta.push(t.proc_cores+" cores in this browser");
if(t.instance_age) meta.push(fmtAge(t.instance_age)+" old");
return `<div class="tr">
<div class="t">${esc(t.title)}<em>${esc(meta.join(" · "))}</em></div>
<div class="b">${t.port ? "instance "+t.port : esc(t.browser)}</div>
<div class="num mono">${t.task_pct.toFixed(1)}%</div>
<div class="num dim">${t.heap_mb} MB</div>
</div>`;}).join("");
$("#tabs").innerHTML = tabRows || `<div class="empty">no tab data yet</div>`;
}
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 70px 66px;"><div>tab</div><div>page work</div><div>heap</div></div>
${w.tabs.map(t=>`<div class="pt">
<div class="r"><b>${esc(t.title)}</b><em>${esc(shortUrl(t.url))}</em></div>
<div class="num mono">${t.task_pct.toFixed(1)}%</div>
<div class="num dim">${t.heap_mb} MB</div></div>`).join("")}
${w.verdict?`<div class="verdict">${esc(w.verdict)}</div>`:""}
</div>` : "";
if(!d.processes.length) return tabBlock+`<div class="empty">no processes seen in this sample</div>`;
const rows=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("");
return tabBlock+`<div class="dgrid"><div class="dlist">
<div class="ph"><div>process / role</div><div>cores</div><div>age</div><div>hot thread</div><div>cpu</div></div>
${rows}</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 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>