vitals/index.html
Christian Sass 52885cad78 icons: one mark in, every size out, and a place to serve them
Three surfaces were showing generated identicons: the forge org avatar
(which is what Discord unfurls), the GitHub org avatar, and the browser
tab, because the app shipped no favicon at all.

make_icons.py turns one SVG or PNG into the seven sizes plus a
multi-resolution favicon.ico. The server now serves /favicon.ico and
/icons/*.png from icons/ when they exist, and index.html asks for them,
so dropping the real mark into icons/ is the whole integration.

Co-developed-with: CLU <clu@binaryranch.com>
2026-10-21 13:35:45 -05:00

629 lines
28 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>
<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>