From 9cdbb433948ef8bf51692228d67a1511ad2c3665 Mon Sep 17 00:00:00 2001 From: CLU Date: Tue, 20 Oct 2026 13:56:26 -0500 Subject: [PATCH] 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 --- icons.py | 138 ++++++++++++++++++++++++++++++++++++++++++ index.html | 146 +++++++++++++++++++++++++++++++++++++-------- server.py | 133 +++++++++++++++++++++++++++++++++++++++-- tests/test_tabs.py | 78 ++++++++++++++++++++++++ 4 files changed, 467 insertions(+), 28 deletions(-) create mode 100644 icons.py create mode 100644 tests/test_tabs.py diff --git a/icons.py b/icons.py new file mode 100644 index 0000000..1277426 --- /dev/null +++ b/icons.py @@ -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" 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() diff --git a/index.html b/index.html index a5a2a88..57e2a0d 100644 --- a/index.html +++ b/index.html @@ -128,6 +128,33 @@ button:hover{border-color:#37424d;color:var(--fg)} .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} @@ -239,7 +266,7 @@ function flagChips(w){ function render(){ const s=state.snap; if(!s) return; const cores=s.cores; - setText($("#meta"), `live · ${new Date(s.t*1000).toLocaleTimeString()} · ${s.sample_ms}ms sample`); + 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); @@ -279,22 +306,20 @@ function render(){ }).join("")); $("#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||[]); if(state.lastTabs!==tabSig){ state.lastTabs=tabSig; - const tabRows = (s.tabs||[]).map(t=>{ - const meta = [shortUrl(t.url)]; - if(t.port) meta.push("port "+t.port); - if(t.proc_cores) meta.push(t.proc_cores+" cores in this browser"); - if(t.instance_age) meta.push(fmtAge(t.instance_age)+" old"); - return `
-
${esc(t.title)}${esc(meta.join(" · "))}
-
${t.port ? "instance "+t.port : esc(t.browser)}
-
${t.task_pct.toFixed(1)}%
-
${t.heap_mb} MB
-
`;}).join(""); - $("#tabs").innerHTML = tabRows || `
no tab data yet
`; + 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) ? `` : ""; - if(!d.processes.length) return tabBlock+`
no processes seen in this sample
`; - const rows=d.processes.map(p=>{ + + const roles = d.roles || []; + const roleBlock = roles.length ? ` +
role
cores
RAM
age
+ ${roles.map(r=>` +
+
+
${esc(r.role)}${r.count>1?`×${r.count}`:""}
+
${(r.cpu||0).toFixed(2)}
+
${fmtRam(r.rss_mb||0)}
+
${r.count>1 ? fmtAge(r.age_max)+" max" : fmtAge(r.age_max)}
+
+ ${r.gloss?`
${esc(r.gloss)}
`:""} + ${r.hint?`
${esc(r.hint)}
`:""} +
pid ${r.pids.slice(0,12).join(" · ")}${r.pids.length>12?" …":""}
+
`).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(",")); @@ -455,13 +497,21 @@ function detailHTML(w,d){
${(p.cpu||0).toFixed(2)}
${fmtAge((p.age_h||0))}
${(p.threads&&p.threads[0])?p.threads[0].name.slice(0,10):"—"}
-
${p.threads&&p.threads[0]?p.threads[0].ticks>0?Math.round(p.threads[0].ticks/100)+"s":"":"—"}
+
${p.threads&&p.threads[0]?p.threads[0].ticks>0?Math.round(p.threads[0].ticks/100)+"s":"":""}
${esc(why.join(" · "))}
`; }).join(""); - return tabBlock+`
-
process / role
cores
age
hot thread
cpu
- ${rows}
+ + const procTable = procRows ? ` +
${d.processes.length} individual processes +
+
process / role
cores
age
hot thread
cpu
+ ${procRows} +
` : ""; + + return tabBlock + `
+ ${roleBlock || `
no processes seen in this sample
`} + ${procTable}
last 5 minutes · cores
${bigChart(d.series, colorFor(w.key))}
`; } @@ -470,6 +520,54 @@ 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 `
collecting…
`; diff --git a/server.py b/server.py index 7d30a29..f86824c 100644 --- a/server.py +++ b/server.py @@ -25,6 +25,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs import browsers +import icons HZ = os.sysconf("SC_CLK_TCK") CORES = os.cpu_count() or 1 @@ -33,6 +34,90 @@ LABELS_PATH = os.path.join(HERE, "labels.json") SAMPLE_S = 2.0 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 ---- @@ -496,6 +581,7 @@ class Sampler(threading.Thread): v["cpu"] for k, v in res.get("procs", {}).items() if pid_key.get(k) == w["key"]) / 100, 2)}) tabs.sort(key=lambda t: -t["task_pct"]) + tabs_m = merge_tabs(tabs) w_cores = w["cpu"] / 100.0 if tabs: 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"(compositor / raster / media), not by a tab") 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: verdict = "" inst_detail = [] @@ -514,7 +600,7 @@ class Sampler(threading.Thread): "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), ""), }) - for t in tabs: + for t in tabs_m: tabs_flat.append({"browser": w["name"], "key": w["key"], **t}) out.append({ "key": w["key"], "name": w["name"], @@ -526,7 +612,7 @@ class Sampler(threading.Thread): "instances": len(w["instances"]), "roles": {k: round(v / 100, 2) for k, v in w["roles"].most_common(6) if v > 0}, "flags": sorted(w["flags"]), - "tabs": tabs[:20], + "tabs": tabs_m[:20], "verdict": verdict, "instances_detail": inst_detail, "has_cdp": bool(tabs), @@ -550,6 +636,7 @@ class Sampler(threading.Thread): "workloads": out, "flags": flags, "tabs": tabs_flat[:40], "browsers": sorted(browsers_seen, key=lambda b: b["port"]), "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), "sample_ms": round((time.time() - t0) * 1000), } @@ -575,12 +662,34 @@ class Sampler(threading.Thread): "threads": read_threads(pid, 6), }) 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 = [] if hist: keys = [k for _, h in hist for k in h] if key in keys: 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): self.refresh_containers() @@ -651,6 +760,20 @@ class Handler(BaseHTTPRequestHandler): return self._send(json.dumps( DEMO["data"]["details"].get(key, {"key": key, "processes": [], "series": []}))) 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) @@ -661,6 +784,8 @@ def main(): ap.add_argument("--demo", action="store_true", help="serve the canned demo snapshot (documentation, screenshots, UI work)") args = ap.parse_args() + global PORT + PORT = args.port if args.demo: load_demo() print("Vitals in DEMO mode — canned data, no /proc sampling, no CDP", flush=True) diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 0000000..8b56f7b --- /dev/null +++ b/tests/test_tabs.py @@ -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()