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>
629 lines
28 KiB
HTML
629 lines
28 KiB
HTML
<!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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|