vitals/index.html
Christian Sass 9d065153b0 Vitals 0.1.0: workload map with browser tab accounting
Process monitors stop at the browser boundary, so the thing that usually
pegs a machine is invisible: forty identical processes, no age, no tab
names, no idea which page is the offender.

Vitals resolves every process to a human identity (container, flatpak,
systemd unit, tree root, or script), then joins the OS view to the
browser's own DevTools counters so each tab carries its own page-work
number beside the cores its browser instance is burning. The gap between
those two numbers is the diagnosis: idle page, burning compositor.

- dependency-free sampler over /proc, 2s cadence, 5 min in-memory history
- minimal CDP client (WebSocket + JSON) with no third-party packages
- both unit systems side by side: percent of machine and fractional cores
- flags for leaked automation instances and live WebRTC calls
- user service with CPUQuota so the monitor can never be the problem
- identity tests that need no root, server, or browser

MIT licensed. Runs on Linux, reads /proc, asks for no privilege.
2026-10-19 16:42:54 -05:00

387 lines
18 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)}
</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"><b id="busy">–</b><i>of machine busy</i></div>
<div class="stat"><b id="cores">–</b><i>cores in use</i></div>
<div class="stat"><b id="topname" style="font-size:22px">–</b><i>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:""};
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 spark(series, color){
const w=92,h=22;
if(!series||series.length<2) return `<svg class="spark" viewBox="0 0 ${w} ${h}"></svg>`;
const max=Math.max(...series,0.1);
const pts=series.map((v,i)=>[i/(series.length-1)*w, h-2-(v/max)*(h-4)]);
const d=pts.map((p,i)=>(i?"L":"M")+p[0].toFixed(1)+" "+p[1].toFixed(1)).join(" ");
return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<path d="${d}" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round"/></svg>`;
}
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;
$("#meta").textContent = `sampled ${new Date(s.t*1000).toLocaleTimeString()} · ${s.sample_ms}ms per sample`;
if(s.host) $("#hostlabel").textContent = "workload map · " + s.host;
$("#busy").innerHTML = `${s.total_pct}<small>%</small>`;
$("#cores").innerHTML = `${s.total_busy.toFixed(2)}<small> / ${cores}</small>`;
const top=s.workloads[0];
$("#topname").textContent = top? top.name : "—";
$("#topname").parentElement.querySelector("i").textContent =
top ? `largest workload · ${top.cpu.toFixed(2)} cores` : "largest workload";
$("#selfcost").textContent = `${s.self_cost.toFixed(2)} cores`;
// machine bar: top 8 workloads + rest, then free
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);
let bar="", leg="";
top8.forEach(w=>{
bar+=`<div style="width:${(w.cpu/cores*100).toFixed(2)}%;background:${colorFor(w.key)}"></div>`;
leg+=`<span><i style="background:${colorFor(w.key)}"></i>${w.name} ${w.cpu.toFixed(2)}</span>`;
});
if(rest>0.02){
bar+=`<div style="width:${(rest/cores*100).toFixed(2)}%;background:#3a434c"></div>`;
leg+=`<span><i style="background:#3a434c"></i>rest ${rest.toFixed(2)}</span>`;
}
$("#machine").innerHTML=bar; $("#legend").innerHTML=leg;
$("#flags").innerHTML = (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("");
// browser tabs
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);
$("#banner").innerHTML =
(!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>` : "";
// rows
let list=s.workloads.slice();
if(state.hideIdle) list=list.filter(w=>w.cpu>=0.05);
if(state.q) list=list.filter(w=>(w.name+" "+w.key).toLowerCase().includes(state.q.toLowerCase()));
const by=state.sort;
list.sort((a,b)=> by==="cpu"? b.cpu-a.cpu : by==="ram"? b.rss_mb-a.rss_mb : b.age_h-a.age_h);
const maxCpu=Math.max(0.2,...list.map(w=>w.cpu));
let html="";
list.forEach(w=>{
const c=colorFor(w.key);
const open=state.open.has(w.key);
const d=state.details[w.key];
html+=`<div class="row ${open?"open":""}" data-key="${w.key}">
<div class="nm"><span class="dot" style="background:${c}"></span>
<div class="txt"><div class="t1">${w.name}${flagChips(w)}</div>
<div class="t2">${meta2(w)}</div></div></div>
<div class="bar"><i style="width:${(w.cpu/maxCpu*100).toFixed(1)}%;background:${c}"></i></div>
<div class="num">${w.pct.toFixed(1)}%</div>
<div class="num mono">${w.cpu.toFixed(2)}</div>
<div class="num dim">${fmtRam(w.rss_mb)}</div>
<div class="num dim">${fmtAge(w.age_h)}</div>
<div>${spark(w.series || (d&&d.series), c)}</div>
<div class="chev">${open?"▲":"▼"}</div>
</div>`;
if(open){
html+=`<div class="detail" data-detail="${w.key}">${detailHTML(w,d)}</div>`;
}
});
$("#rows").innerHTML = html || `<div class="empty">nothing matches</div>`;
document.querySelectorAll(".row").forEach(el=>{
el.onclick=()=>{
const k=el.dataset.key;
if(state.open.has(k)){ state.open.delete(k); }
else { state.open.add(k); loadDetail(k); }
render();
};
});
}
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");
state.snap=await r.json();
// top up series for expanded rows
for(const k of state.open) if(state.details[k]) loadDetail(k);
render();
}catch(e){ $("#meta").textContent="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, 2000);
</script>
</body>
</html>