Answer the question the process list asks: what IS that, and whose is it
Eight identical 'call (WebRTC)' rows with no owner is the least useful thing
a monitor can print. Three changes:
- detail panes group processes by role with a plain-English gloss, a pid list,
and a judgement when a group looks abandoned ('11 of 14 idle for over 30
min, oldest 201.7h'). The hint states evidence and never says safe to delete,
because a muted call and a dead call both sit at zero.
- the tab list is one row per URL, duplicates summed with a count badge, each
row carrying the site's own favicon fetched through the local server and
cached on disk. Sites with no icon get a letter tile, never a third-party
icon service.
- Vitals' own cost is in the header, and its own tab is badged 'this screen'.
Tests caught a real bug while writing this: merge_tabs compared each tab to the
group running total, so the busiest tab could never name the row.
Co-developed-with: CLU
This commit is contained in:
parent
95c1c1d3eb
commit
9cdbb43394
4 changed files with 467 additions and 28 deletions
138
icons.py
Normal file
138
icons.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Site icons for Vitals, fetched from the site itself and cached on disk.
|
||||||
|
|
||||||
|
No third-party icon service, no tracking pixel, no CDN in the middle: the icon
|
||||||
|
comes from the origin you are already talking to, over the same network path the
|
||||||
|
browser used. First request for a host hits the network once, then it is served
|
||||||
|
from ~/.cache/vitals/icons for a month.
|
||||||
|
|
||||||
|
If a site has no icon, we return None and the UI draws a letter tile in the
|
||||||
|
site's own hash colour. A missing icon is never an error worth showing.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
CACHE_DIR = os.path.expanduser("~/.cache/vitals/icons")
|
||||||
|
MAX_BYTES = 96 * 1024
|
||||||
|
MAX_AGE_S = 30 * 24 * 3600
|
||||||
|
TIMEOUT = 2.5
|
||||||
|
|
||||||
|
# what a browser would accept, most common first
|
||||||
|
CANDIDATES = ("/favicon.ico", "/favicon.png", "/apple-touch-icon.png")
|
||||||
|
|
||||||
|
|
||||||
|
def _host_of(url):
|
||||||
|
try:
|
||||||
|
p = urlparse(url)
|
||||||
|
if p.scheme not in ("http", "https") or not p.hostname:
|
||||||
|
return None
|
||||||
|
return p.scheme, p.hostname, p.port
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _path_for(host):
|
||||||
|
return os.path.join(CACHE_DIR, hashlib.sha1(host.encode()).hexdigest() + ".img")
|
||||||
|
|
||||||
|
|
||||||
|
def _content_type(data):
|
||||||
|
if data[:4] == b"\x89PNG":
|
||||||
|
return "image/png"
|
||||||
|
if data[:6] in (b"GIF87a", b"GIF89a"):
|
||||||
|
return "image/gif"
|
||||||
|
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||||
|
return "image/webp"
|
||||||
|
if data[:2] == b"\xff\xd8":
|
||||||
|
return "image/jpeg"
|
||||||
|
if data[:4] == b"<svg" or b"<svg" in data[:200]:
|
||||||
|
return "image/svg+xml"
|
||||||
|
return "image/x-icon"
|
||||||
|
|
||||||
|
|
||||||
|
def icon_for(url):
|
||||||
|
"""Return (bytes, content_type) for the site's icon, or None.
|
||||||
|
|
||||||
|
Cached for 30 days. A stale cache entry is refreshed in place; a failure
|
||||||
|
leaves the old one alone rather than blanking a working icon.
|
||||||
|
"""
|
||||||
|
host_bits = _host_of(url)
|
||||||
|
if not host_bits:
|
||||||
|
return None
|
||||||
|
scheme, host, port = host_bits
|
||||||
|
cache_key = f"{host}:{port or ''}"
|
||||||
|
path = _path_for(cache_key)
|
||||||
|
|
||||||
|
fresh = False
|
||||||
|
if os.path.exists(path):
|
||||||
|
age = time.time() - os.path.getmtime(path)
|
||||||
|
if age < MAX_AGE_S:
|
||||||
|
fresh = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
cached = f.read()
|
||||||
|
_refresh_in_background(scheme, host, port, path)
|
||||||
|
return cached, _content_type(cached)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if fresh:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
if data:
|
||||||
|
return data, _content_type(data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data = _fetch(scheme, host, port)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return data, _content_type(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(scheme, host, port):
|
||||||
|
netloc = host if not port else f"{host}:{port}"
|
||||||
|
for suffix in CANDIDATES:
|
||||||
|
url = f"{scheme}://{netloc}{suffix}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; Vitals/0.2; local monitor)",
|
||||||
|
"Accept": "image/*,*/*;q=0.8"})
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||||
|
data = r.read(MAX_BYTES)
|
||||||
|
if data and len(data) > 40:
|
||||||
|
return data
|
||||||
|
except (urllib.error.URLError, socket.timeout, OSError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_in_background(scheme, host, port, path):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def work():
|
||||||
|
data = _fetch(scheme, host, port)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True, name="vitals-icon").start()
|
||||||
144
index.html
144
index.html
|
|
@ -128,6 +128,33 @@ button:hover{border-color:#37424d;color:var(--fg)}
|
||||||
.row .bar i{transition:width .9s linear}
|
.row .bar i{transition:width .9s linear}
|
||||||
.num{font-variant-numeric:tabular-nums}
|
.num{font-variant-numeric:tabular-nums}
|
||||||
.stat, .legend span, .tabs .tr{will-change:auto}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -239,7 +266,7 @@ function flagChips(w){
|
||||||
function render(){
|
function render(){
|
||||||
const s=state.snap; if(!s) return;
|
const s=state.snap; if(!s) return;
|
||||||
const cores=s.cores;
|
const cores=s.cores;
|
||||||
setText($("#meta"), `live · ${new Date(s.t*1000).toLocaleTimeString()} · ${s.sample_ms}ms sample`);
|
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);
|
if(s.host) setText($("#hostlabel"), "workload map · " + s.host);
|
||||||
ease.busyT = s.total_pct; ease.coresT = s.total_busy;
|
ease.busyT = s.total_pct; ease.coresT = s.total_busy;
|
||||||
setText($("#coresmax"), " / " + cores);
|
setText($("#coresmax"), " / " + cores);
|
||||||
|
|
@ -279,22 +306,20 @@ function render(){
|
||||||
}).join(""));
|
}).join(""));
|
||||||
$("#flags").style.display = (s.flags||[]).length ? "flex" : "none";
|
$("#flags").style.display = (s.flags||[]).length ? "flex" : "none";
|
||||||
|
|
||||||
// browser tabs: rebuilt only when the tab data itself changes
|
// 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||[]);
|
const tabSig=JSON.stringify(s.tabs||[]);
|
||||||
if(state.lastTabs!==tabSig){
|
if(state.lastTabs!==tabSig){
|
||||||
state.lastTabs=tabSig;
|
state.lastTabs=tabSig;
|
||||||
const tabRows = (s.tabs||[]).map(t=>{
|
const host=$("#tabs");
|
||||||
const meta = [shortUrl(t.url)];
|
host.innerHTML="";
|
||||||
if(t.port) meta.push("port "+t.port);
|
const tabs=(s.tabs||[]).slice().sort((a,b)=>(b.task_pct||0)-(a.task_pct||0));
|
||||||
if(t.proc_cores) meta.push(t.proc_cores+" cores in this browser");
|
if(!tabs.length){
|
||||||
if(t.instance_age) meta.push(fmtAge(t.instance_age)+" old");
|
const e=document.createElement("div"); e.className="empty";
|
||||||
return `<div class="tr">
|
e.textContent = s.brave_cdp ? "no tabs reported yet" : "no tab data yet";
|
||||||
<div class="t">${esc(t.title)}<em>${esc(meta.join(" · "))}</em></div>
|
host.appendChild(e);
|
||||||
<div class="b">${t.port ? "instance "+t.port : esc(t.browser)}</div>
|
}
|
||||||
<div class="num mono">${t.task_pct.toFixed(1)}%</div>
|
tabs.forEach(t=>host.appendChild(tabRow(t, s.self_url)));
|
||||||
<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 brave = (s.workloads||[]).find(w=>/brave/i.test(w.name) && w.cpu > 0.05);
|
||||||
const banner = (!s.brave_cdp && brave) ? `<div class="banner">
|
const banner = (!s.brave_cdp && brave) ? `<div class="banner">
|
||||||
|
|
@ -434,17 +459,34 @@ function fmtRam(mb){ return mb>=1024 ? (mb/1024).toFixed(2)+" GB" : mb+" MB"; }
|
||||||
|
|
||||||
function detailHTML(w,d){
|
function detailHTML(w,d){
|
||||||
if(!d) return `<div class="empty">loading…</div>`;
|
if(!d) return `<div class="empty">loading…</div>`;
|
||||||
|
|
||||||
const tabBlock = (w.tabs&&w.tabs.length) ? `
|
const tabBlock = (w.tabs&&w.tabs.length) ? `
|
||||||
<div style="margin-bottom:14px">
|
<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>
|
<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">
|
${w.tabs.map(t=>`<div class="pt">
|
||||||
<div class="r"><b>${esc(t.title)}</b><em>${esc(shortUrl(t.url))}</em></div>
|
<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.toFixed(1)}%</div>
|
<div class="num mono">${(t.task_pct||0).toFixed(1)}%</div>
|
||||||
<div class="num dim">${t.heap_mb} MB</div></div>`).join("")}
|
<div class="num dim">${t.heap_mb||0} MB</div></div>`).join("")}
|
||||||
${w.verdict?`<div class="verdict">${esc(w.verdict)}</div>`:""}
|
${w.verdict?`<div class="verdict">${esc(w.verdict)}</div>`:""}
|
||||||
</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 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=[];
|
const why=[];
|
||||||
if(p.windows.length) why.push("window: "+p.windows[0]);
|
if(p.windows.length) why.push("window: "+p.windows[0]);
|
||||||
if(p.ports.length) why.push("ports "+p.ports.join(","));
|
if(p.ports.length) why.push("ports "+p.ports.join(","));
|
||||||
|
|
@ -455,13 +497,21 @@ function detailHTML(w,d){
|
||||||
<div class="num mono">${(p.cpu||0).toFixed(2)}</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">${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].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="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 class="why">${esc(why.join(" · "))}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
return tabBlock+`<div class="dgrid"><div class="dlist">
|
|
||||||
|
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>
|
<div class="ph"><div>process / role</div><div>cores</div><div>age</div><div>hot thread</div><div>cpu</div></div>
|
||||||
${rows}</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>
|
<div class="chart"><div class="cap">last 5 minutes · cores</div>
|
||||||
${bigChart(d.series, colorFor(w.key))}</div></div>`;
|
${bigChart(d.series, colorFor(w.key))}</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -470,6 +520,54 @@ function shortUrl(u){
|
||||||
try{ const x=new URL(u); return x.hostname.replace(/^www\./,"")+x.pathname.slice(0,40); }
|
try{ const x=new URL(u); return x.hostname.replace(/^www\./,"")+x.pathname.slice(0,40); }
|
||||||
catch(e){ return (u||"").slice(0,50); }
|
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){
|
function bigChart(series,color){
|
||||||
const w=284,h=120;
|
const w=284,h=120;
|
||||||
if(!series||series.length<2) return `<div class="empty">collecting…</div>`;
|
if(!series||series.length<2) return `<div class="empty">collecting…</div>`;
|
||||||
|
|
|
||||||
133
server.py
133
server.py
|
|
@ -25,6 +25,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
import browsers
|
import browsers
|
||||||
|
import icons
|
||||||
|
|
||||||
HZ = os.sysconf("SC_CLK_TCK")
|
HZ = os.sysconf("SC_CLK_TCK")
|
||||||
CORES = os.cpu_count() or 1
|
CORES = os.cpu_count() or 1
|
||||||
|
|
@ -33,6 +34,90 @@ LABELS_PATH = os.path.join(HERE, "labels.json")
|
||||||
|
|
||||||
SAMPLE_S = 2.0
|
SAMPLE_S = 2.0
|
||||||
HISTORY_N = 150 # 5 minutes at 2s
|
HISTORY_N = 150 # 5 minutes at 2s
|
||||||
|
PORT = 8791 # replaced by main(); used to recognise our own tab
|
||||||
|
|
||||||
|
# What a role actually means, in words a person can act on. Shown in the
|
||||||
|
# detail pane next to the count, because "call (WebRTC) x8" is not an answer.
|
||||||
|
ROLE_GLOSS = {
|
||||||
|
"call (WebRTC)": "a page process holding a live audio/video connection. "
|
||||||
|
"Idle for hours means a call that ended and never died.",
|
||||||
|
"tab renderer": "runs one tab's page: HTML, script, layout, and its video.",
|
||||||
|
"gpu": "one compositor per browser. Draws every window and tab.",
|
||||||
|
"main": "the browser process itself: UI, network, extensions.",
|
||||||
|
"utility": "a background service belonging to the browser.",
|
||||||
|
"video_captureservice": "holds the camera. Only exists while something captures.",
|
||||||
|
"audio": "the audio service: playback and mixing for the whole browser.",
|
||||||
|
"audioservice": "the audio service: playback and mixing for the whole browser.",
|
||||||
|
"network": "the browser's network service. Every request goes through it.",
|
||||||
|
"networkservice": "the browser's network service. Every request goes through it.",
|
||||||
|
"storage": "the browser's storage service.",
|
||||||
|
"storageservice": "the browser's storage service.",
|
||||||
|
"datadecoder": "parses data for the page, in a throwaway process.",
|
||||||
|
"app": "part of this app, and Chromium does not classify it further.",
|
||||||
|
"monitor": "Vitals itself, measuring this screen.",
|
||||||
|
"headless browser": "a browser automation instance with no window.",
|
||||||
|
"renderer": "a page process in a headless browser instance.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def url_key(url):
|
||||||
|
"""Normalise a URL so two tabs of the same page collapse into one row."""
|
||||||
|
try:
|
||||||
|
p = urlparse(url or "")
|
||||||
|
if not p.netloc:
|
||||||
|
return (url or "").strip()
|
||||||
|
tail = (p.path or "/") + (("?" + p.query) if p.query else "")
|
||||||
|
return f"{p.scheme}://{p.netloc}{tail}"
|
||||||
|
except ValueError:
|
||||||
|
return (url or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def merge_tabs(tabs):
|
||||||
|
"""One row per URL. Duplicated tabs are added up, not listed twice."""
|
||||||
|
groups = {}
|
||||||
|
for t in tabs:
|
||||||
|
k = url_key(t.get("url", ""))
|
||||||
|
g = groups.get(k)
|
||||||
|
if g is None:
|
||||||
|
g = dict(t)
|
||||||
|
g["tab_count"] = 1
|
||||||
|
g["_busiest"] = t.get("task_pct") or 0
|
||||||
|
groups[k] = g
|
||||||
|
else:
|
||||||
|
g["tab_count"] += 1
|
||||||
|
g["task_pct"] = round(g.get("task_pct", 0) + (t.get("task_pct") or 0), 1)
|
||||||
|
g["heap_mb"] = round((g.get("heap_mb") or 0) + (t.get("heap_mb") or 0), 1)
|
||||||
|
g["nodes"] = int(g.get("nodes") or 0) + int(t.get("nodes") or 0)
|
||||||
|
# the busiest tab of the group names the row
|
||||||
|
if (t.get("task_pct") or 0) > g["_busiest"]:
|
||||||
|
g["_busiest"] = t.get("task_pct") or 0
|
||||||
|
g["title"] = t.get("title")
|
||||||
|
out = list(groups.values())
|
||||||
|
for g in out:
|
||||||
|
g.pop("_busiest", None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def leak_hint(role, items):
|
||||||
|
"""Say out loud when a group of processes looks abandoned, with the evidence.
|
||||||
|
|
||||||
|
A muted call and an abandoned call both sit at zero, so this never says
|
||||||
|
"safe to delete": it states the count, the age, and the condition.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return ""
|
||||||
|
idle = [p for p in items if (p.get("cpu") or 0) < 0.01 and (p.get("age_h") or 0) > 0.5]
|
||||||
|
oldest = max((p.get("age_h") or 0) for p in items)
|
||||||
|
if role in ("call (WebRTC)", "tab renderer") and idle and oldest > 1.0:
|
||||||
|
if len(idle) == len(items):
|
||||||
|
return (f"all {len(items)} idle for over 30 min, oldest {oldest:.1f}h. "
|
||||||
|
f"If you are not in a call, these can go.")
|
||||||
|
if len(idle) >= 0.5 * len(items):
|
||||||
|
return (f"{len(idle)} of {len(items)} idle for over 30 min, oldest {oldest:.1f}h. "
|
||||||
|
f"If you are not in a call, those can go.")
|
||||||
|
if role == "video_captureservice" and len(idle) == len(items):
|
||||||
|
return "the camera is held but nothing is using it"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- labels ----
|
# ---------------------------------------------------------------- labels ----
|
||||||
|
|
@ -496,6 +581,7 @@ class Sampler(threading.Thread):
|
||||||
v["cpu"] for k, v in res.get("procs", {}).items()
|
v["cpu"] for k, v in res.get("procs", {}).items()
|
||||||
if pid_key.get(k) == w["key"]) / 100, 2)})
|
if pid_key.get(k) == w["key"]) / 100, 2)})
|
||||||
tabs.sort(key=lambda t: -t["task_pct"])
|
tabs.sort(key=lambda t: -t["task_pct"])
|
||||||
|
tabs_m = merge_tabs(tabs)
|
||||||
w_cores = w["cpu"] / 100.0
|
w_cores = w["cpu"] / 100.0
|
||||||
if tabs:
|
if tabs:
|
||||||
if w_cores - tab_cpu > 0.5 and tab_cpu < w_cores * 0.4:
|
if w_cores - tab_cpu > 0.5 and tab_cpu < w_cores * 0.4:
|
||||||
|
|
@ -503,7 +589,7 @@ class Sampler(threading.Thread):
|
||||||
f"{w_cores - tab_cpu:.2f} cores burned by browser internals "
|
f"{w_cores - tab_cpu:.2f} cores burned by browser internals "
|
||||||
f"(compositor / raster / media), not by a tab")
|
f"(compositor / raster / media), not by a tab")
|
||||||
elif tab_cpu > 0.2:
|
elif tab_cpu > 0.2:
|
||||||
verdict = f"page work: {tab_cpu:.2f} cores across {len(tabs)} tab(s)"
|
verdict = f"page work: {tab_cpu:.2f} cores across {len(tabs_m)} site(s)"
|
||||||
else:
|
else:
|
||||||
verdict = ""
|
verdict = ""
|
||||||
inst_detail = []
|
inst_detail = []
|
||||||
|
|
@ -514,7 +600,7 @@ class Sampler(threading.Thread):
|
||||||
"tab": next((t["title"] for t in tabs if t.get("instance") == inst), ""),
|
"tab": next((t["title"] for t in tabs if t.get("instance") == inst), ""),
|
||||||
"url": next((t["url"] for t in tabs if t.get("instance") == inst), ""),
|
"url": next((t["url"] for t in tabs if t.get("instance") == inst), ""),
|
||||||
})
|
})
|
||||||
for t in tabs:
|
for t in tabs_m:
|
||||||
tabs_flat.append({"browser": w["name"], "key": w["key"], **t})
|
tabs_flat.append({"browser": w["name"], "key": w["key"], **t})
|
||||||
out.append({
|
out.append({
|
||||||
"key": w["key"], "name": w["name"],
|
"key": w["key"], "name": w["name"],
|
||||||
|
|
@ -526,7 +612,7 @@ class Sampler(threading.Thread):
|
||||||
"instances": len(w["instances"]),
|
"instances": len(w["instances"]),
|
||||||
"roles": {k: round(v / 100, 2) for k, v in w["roles"].most_common(6) if v > 0},
|
"roles": {k: round(v / 100, 2) for k, v in w["roles"].most_common(6) if v > 0},
|
||||||
"flags": sorted(w["flags"]),
|
"flags": sorted(w["flags"]),
|
||||||
"tabs": tabs[:20],
|
"tabs": tabs_m[:20],
|
||||||
"verdict": verdict,
|
"verdict": verdict,
|
||||||
"instances_detail": inst_detail,
|
"instances_detail": inst_detail,
|
||||||
"has_cdp": bool(tabs),
|
"has_cdp": bool(tabs),
|
||||||
|
|
@ -550,6 +636,7 @@ class Sampler(threading.Thread):
|
||||||
"workloads": out, "flags": flags, "tabs": tabs_flat[:40],
|
"workloads": out, "flags": flags, "tabs": tabs_flat[:40],
|
||||||
"browsers": sorted(browsers_seen, key=lambda b: b["port"]),
|
"browsers": sorted(browsers_seen, key=lambda b: b["port"]),
|
||||||
"brave_cdp": any(b["port"] == 9222 for b in browsers_seen),
|
"brave_cdp": any(b["port"] == 9222 for b in browsers_seen),
|
||||||
|
"self_url": f"http://127.0.0.1:{PORT}/",
|
||||||
"self_cost": round(self.self_cost / 100, 2),
|
"self_cost": round(self.self_cost / 100, 2),
|
||||||
"sample_ms": round((time.time() - t0) * 1000),
|
"sample_ms": round((time.time() - t0) * 1000),
|
||||||
}
|
}
|
||||||
|
|
@ -575,12 +662,34 @@ class Sampler(threading.Thread):
|
||||||
"threads": read_threads(pid, 6),
|
"threads": read_threads(pid, 6),
|
||||||
})
|
})
|
||||||
procs.sort(key=lambda p: (-p["cpu"], -p["rss_mb"]))
|
procs.sort(key=lambda p: (-p["cpu"], -p["rss_mb"]))
|
||||||
|
# One line per role, because eight identical "call (WebRTC)" rows with
|
||||||
|
# no owner is the least useful thing a monitor can print.
|
||||||
|
by_role = collections.defaultdict(list)
|
||||||
|
for p in procs:
|
||||||
|
by_role[p["role"]].append(p)
|
||||||
|
role_groups = []
|
||||||
|
for role, items in sorted(by_role.items(),
|
||||||
|
key=lambda kv: (-sum(i["cpu"] for i in kv[1]),
|
||||||
|
-sum(i["rss_mb"] for i in kv[1]))):
|
||||||
|
ages = [i["age_h"] for i in items]
|
||||||
|
role_groups.append({
|
||||||
|
"role": role,
|
||||||
|
"count": len(items),
|
||||||
|
"cpu": round(sum(i["cpu"] for i in items), 2),
|
||||||
|
"rss_mb": sum(i["rss_mb"] for i in items),
|
||||||
|
"age_min": round(min(ages), 1),
|
||||||
|
"age_max": round(max(ages), 1),
|
||||||
|
"gloss": ROLE_GLOSS.get(role, ""),
|
||||||
|
"hint": leak_hint(role, items),
|
||||||
|
"pids": [i["pid"] for i in items],
|
||||||
|
"top_thread": next((i["threads"][0]["name"] for i in items if i.get("threads")), ""),
|
||||||
|
})
|
||||||
series = []
|
series = []
|
||||||
if hist:
|
if hist:
|
||||||
keys = [k for _, h in hist for k in h]
|
keys = [k for _, h in hist for k in h]
|
||||||
if key in keys:
|
if key in keys:
|
||||||
series = [round(h.get(key, 0.0), 2) for _, h in hist]
|
series = [round(h.get(key, 0.0), 2) for _, h in hist]
|
||||||
return {"key": key, "processes": procs, "series": series[-120:]}
|
return {"key": key, "processes": procs, "roles": role_groups, "series": series[-120:]}
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.refresh_containers()
|
self.refresh_containers()
|
||||||
|
|
@ -651,6 +760,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return self._send(json.dumps(
|
return self._send(json.dumps(
|
||||||
DEMO["data"]["details"].get(key, {"key": key, "processes": [], "series": []})))
|
DEMO["data"]["details"].get(key, {"key": key, "processes": [], "series": []})))
|
||||||
return self._send(json.dumps(SAMPLER.detail(key)))
|
return self._send(json.dumps(SAMPLER.detail(key)))
|
||||||
|
if u.path == "/api/icon":
|
||||||
|
target = parse_qs(u.query).get("u", [""])[0]
|
||||||
|
if DEMO["data"]:
|
||||||
|
return self._send(b"", "image/x-icon", 404)
|
||||||
|
got = icons.icon_for(target)
|
||||||
|
if not got:
|
||||||
|
return self._send(b"", "image/x-icon", 404)
|
||||||
|
data, ctype = got
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Cache-Control", "private, max-age=86400")
|
||||||
|
self.end_headers()
|
||||||
|
return self.wfile.write(data)
|
||||||
return self._send("not found", "text/plain", 404)
|
return self._send("not found", "text/plain", 404)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -661,6 +784,8 @@ def main():
|
||||||
ap.add_argument("--demo", action="store_true",
|
ap.add_argument("--demo", action="store_true",
|
||||||
help="serve the canned demo snapshot (documentation, screenshots, UI work)")
|
help="serve the canned demo snapshot (documentation, screenshots, UI work)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
global PORT
|
||||||
|
PORT = args.port
|
||||||
if args.demo:
|
if args.demo:
|
||||||
load_demo()
|
load_demo()
|
||||||
print("Vitals in DEMO mode — canned data, no /proc sampling, no CDP", flush=True)
|
print("Vitals in DEMO mode — canned data, no /proc sampling, no CDP", flush=True)
|
||||||
|
|
|
||||||
78
tests/test_tabs.py
Normal file
78
tests/test_tabs.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Tests for site grouping and the plain-English role judgements."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import server # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestUrlKey(unittest.TestCase):
|
||||||
|
def test_fragments_collapse(self):
|
||||||
|
a = server.url_key("https://site.com/page#one")
|
||||||
|
b = server.url_key("https://site.com/page#two")
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
|
||||||
|
def test_query_is_part_of_identity(self):
|
||||||
|
self.assertNotEqual(server.url_key("https://site.com/?tab=1"),
|
||||||
|
server.url_key("https://site.com/?tab=2"))
|
||||||
|
|
||||||
|
def test_empty_path_becomes_slash(self):
|
||||||
|
self.assertEqual(server.url_key("https://site.com"), "https://site.com/")
|
||||||
|
|
||||||
|
def test_junk_does_not_raise(self):
|
||||||
|
self.assertEqual(server.url_key(""), "")
|
||||||
|
self.assertEqual(server.url_key(None), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeTabs(unittest.TestCase):
|
||||||
|
def test_same_url_is_summed_with_a_count(self):
|
||||||
|
tabs = [
|
||||||
|
{"url": "https://a.com/x", "title": "A", "task_pct": 4.0, "heap_mb": 100, "nodes": 10},
|
||||||
|
{"url": "https://a.com/x#frag", "title": "A again", "task_pct": 6.0, "heap_mb": 50, "nodes": 5},
|
||||||
|
{"url": "https://b.com/", "title": "B", "task_pct": 1.0, "heap_mb": 10, "nodes": 1},
|
||||||
|
]
|
||||||
|
out = server.merge_tabs(tabs)
|
||||||
|
self.assertEqual(len(out), 2)
|
||||||
|
a = next(t for t in out if "a.com" in t["url"])
|
||||||
|
self.assertEqual(a["tab_count"], 2)
|
||||||
|
self.assertAlmostEqual(a["task_pct"], 10.0)
|
||||||
|
self.assertEqual(a["heap_mb"], 150)
|
||||||
|
self.assertEqual(a["nodes"], 15)
|
||||||
|
|
||||||
|
def test_distinct_urls_stay_separate(self):
|
||||||
|
out = server.merge_tabs([{"url": "https://a/1", "task_pct": 1},
|
||||||
|
{"url": "https://a/2", "task_pct": 1}])
|
||||||
|
self.assertEqual(len(out), 2)
|
||||||
|
|
||||||
|
def test_busiest_title_wins(self):
|
||||||
|
out = server.merge_tabs([{"url": "https://a/", "title": "quiet", "task_pct": 0.1},
|
||||||
|
{"url": "https://a/", "title": "loud", "task_pct": 9.0}])
|
||||||
|
self.assertEqual(out[0]["title"], "loud")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeakHint(unittest.TestCase):
|
||||||
|
def idle(self, age_h, cpu=0.0):
|
||||||
|
return {"cpu": cpu, "age_h": age_h}
|
||||||
|
|
||||||
|
def test_old_idle_calls_are_called_out(self):
|
||||||
|
hint = server.leak_hint("call (WebRTC)", [self.idle(20), self.idle(9), self.idle(0.2, 0.1)])
|
||||||
|
self.assertIn("2 of 3", hint)
|
||||||
|
self.assertIn("20.0h", hint)
|
||||||
|
|
||||||
|
def test_a_busy_group_is_not_accused(self):
|
||||||
|
self.assertEqual(server.leak_hint("call (WebRTC)",
|
||||||
|
[self.idle(20, 0.5), self.idle(20, 0.4)]), "")
|
||||||
|
|
||||||
|
def test_young_processes_are_not_accused(self):
|
||||||
|
self.assertEqual(server.leak_hint("call (WebRTC)", [self.idle(0.1), self.idle(0.2)]), "")
|
||||||
|
|
||||||
|
def test_hint_never_says_safe_to_delete(self):
|
||||||
|
hint = server.leak_hint("call (WebRTC)", [self.idle(30)])
|
||||||
|
self.assertNotIn("safe", hint.lower())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in a new issue