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.
This commit is contained in:
Christian Sass 2026-10-19 16:42:54 -05:00
commit 9d065153b0
12 changed files with 1599 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.venv/
venv/
*.log
.DS_Store
.vitals-*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Binary Ranch, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

151
README.md Normal file
View file

@ -0,0 +1,151 @@
# Vitals
**A workload map for a browser-shaped computer.**
Every process monitor tells you which PID is hot. Nobody thinks in PIDs. Vitals
groups processes under the thing a human named — an app, a container, an agent,
a browser instance — and then crosses the boundary every other monitor stops at:
the browser. Tabs get their own numbers, so you can tell the Excel sheet that is
crashing your machine from the video call.
![Vitals](docs/screenshot.png)
## The problem
On a normal desktop, one browser is 40 to 60 processes. GNOME System Monitor
shows them as 60 identical rows named `brave`, with no age, no parent, and no
idea which one is the tab that is killing you. When ten headless browser
instances leaked from an automation run and sat on a staging page for seven days,
the monitor could not say any of this:
- that they were orphans (their launcher was long dead)
- that they were seven days old
- that their pages were idle while their GPU processes burned six cores
- which instance was which
Vitals answers all four, in one screen, without root.
## What it actually measures
Three layers, joined:
**1. Identity.** Every process is resolved to a human name using the strongest
signal available, in order: docker container, flatpak app id, systemd unit, the
top of its own process tree, or the script it runs. Kernel threads collapse to
one row. `labels.json` is where you teach it your own names.
**2. Tabs.** Chrome-family browsers that speak the Chrome DevTools Protocol are
attached per tab. `Performance.getMetrics` gives main-thread task time, script
time, heap and DOM nodes for each tab. `SystemInfo.getProcessInfo` gives
per-process CPU where the ids are real PIDs. Joining those two is what makes the
verdict possible.
**3. Verdict.** A sentence, in English:
> pages are idle (0.02 cores of page work) — 6.93 cores burned by browser
> internals (compositor / raster / media), not by a tab
That is the diagnosis no process list can produce, because the offender is a
child process of a browser that owns an idle page.
## Units, and why your numbers never matched
System monitors disagree about denominators. GNOME System Monitor reports
**percent of the whole machine**. `top` and `ps` report **percent of one core**.
The same process is `19%` in one and `225%` in the other. Vitals shows both, on
the same row, always labeled:
- `% machine` — share of all cores, matches your desktop monitor
- `cores` — the fractional core count, matches `top`
## Quickstart
Requirements: Linux, Python 3.9+, a `/proc` filesystem. No dependencies, no
root, no agent installed inside your apps.
```bash
git clone https://forge.binaryranch.com/sastian/vitals.git
cd vitals
python3 server.py # http://127.0.0.1:8791
```
Optional: run it as a user service so it is either up or reported, never a
silently orphaned process.
```bash
./install.sh # systemd --user unit, enabled at login
```
Options: `--port N`, `--host ADDR`, `VITALS_PORT` env var.
## Getting browser tabs
Tabs come from the DevTools Protocol, which a browser only exposes if it was
started with a debugging port. Chromium instances launched by automation usually
already have one, and Vitals scans ports 9500-9599 automatically. For your own
browser, add the flag once:
```bash
flatpak kill com.brave.Browser && flatpak run com.brave.Browser --remote-debugging-port=9222
```
or for Chrome/Chromium:
```bash
google-chrome --remote-debugging-port=9222
```
Tabs restore from session. Without the flag, Vitals still shows the browser's
process breakdown, age, memory and per-role CPU. It just cannot name the tabs,
and it tells you so instead of guessing.
**Security note, stated plainly:** a local debugging port lets any local process
drive that browser, including reading cookies for sites it is logged into. It
binds to `127.0.0.1` only, which keeps it off the network, but it is a real
tradeoff on a multi-user machine. Use a dedicated profile if that matters, and
close the browser when you do not need tab-level detail.
## Reading the screen
- **Machine bar** — every core on the box, split by workload. Hover a legend
entry to find its row.
- **Workload rows** — name, roles that are consuming CPU, `% machine`, `cores`,
RAM, age, 5-minute trend. Click any row to open its processes and history.
- **Flags** — `leaked` means reparented to PID 1 and older than an hour, which
is the signature of an automation run that died without cleaning up. `on a
call` means a WebRTC renderer is actually burning, not merely present.
- **Browser tabs** — every tab from every DevTools-speaking browser, sorted by
page work, with the owning browser instance, its port, its age, and how many
cores that whole instance is using. The gap between those two numbers is the
whole point.
## What it deliberately does not do
- **No root, no eBPF, no agents.** It reads `/proc`, `cgroup`, `flatpak ps`,
`docker ps`, `ss` and `wmctrl`. Everything else is arithmetic.
- **No killing.** Vitals reports. Reaping is a separate, explicit action.
- **No history beyond five minutes in memory.** Nothing is written to disk.
- **No guessing about tabs.** If a browser has no debugging port, its tabs are
unknown, and the UI says so.
## Limitations
`/proc/<pid>/fd` and `/proc/<pid>/io` require ptrace permission, so socket and
file attribution is limited to what `ss` reports. Per-tab GPU cost is inferred
from the instance's process breakdown rather than attributed per tab, because
Chromium does not expose that mapping over CDP. Kernel-thread grouping is
coarse by design.
## Roadmap
- reaping leaked browser instances from the UI, with the age and orphan
evidence shown before the button
- persisted history, so a spike at 03:00 is still there at 09:00
- per-tab process attribution via `Target.getTargetInfo` where the platform
allows it
- a second adapter for non-Chromium browsers through their own remote protocols
- macOS and BSD sampling backends, same model
## License
MIT. See [LICENSE](LICENSE).

154
browsers.py Normal file
View file

@ -0,0 +1,154 @@
"""
Browser layer for Vitals — per-TAB accounting, not per-PID.
A browser is the top layer of the operating system: it is where the work
actually happens. Chrome-family browsers speak CDP when started with
--remote-debugging-port, and that gives us the two halves of the answer:
Performance.getMetrics per-tab TaskDuration / ScriptDuration / heap / nodes
SystemInfo.getProcessInfo per-process CPU (ids are real PIDs)
Join them and you can say: "this tab is idle, its GPU process is rasterizing
in a loop" — which is the signature of a browser eating a machine while
looking innocent.
"""
import time
import cdp
KNOWN_PORTS = [9222] # Brave/Chrome launched with the flag
SCAN_RANGE = range(9500, 9600) # headless automation instances
class TabConn:
def __init__(self, port):
self.port = port
self.ws = None
self.sessions = {} # target_id -> sessionId
self.prev = {} # target_id -> {metric: value}
self.prev_procs = {} # pid -> cpuTime
self.last_ok = 0.0
self.error = ""
def close(self):
if self.ws:
self.ws.close()
self.ws = None
self.sessions.clear()
def connect(self):
self.ws = cdp.WS(cdp.browser_ws(self.port), timeout=2.0)
self.sessions.clear()
self.prev.clear()
self.prev_procs.clear()
def poll(self, dt):
"""Return dict with tabs and per-process cpu for this browser."""
if not self.ws:
self.connect()
targets = [t for t in cdp.targets(self.port) if t.get("type") == "page"]
live = {t["id"] for t in targets}
for tid in list(self.sessions):
if tid not in live:
self.sessions.pop(tid, None)
self.prev.pop(tid, None)
tabs = []
for t in targets:
tid = t["id"]
try:
if tid not in self.sessions:
self.sessions[tid] = self.ws.call(
"Target.attachToTarget", {"targetId": tid, "flatten": True})["sessionId"]
self.ws.call("Performance.enable", {}, self.sessions[tid])
s = self.sessions[tid]
m = {x["name"]: x["value"]
for x in self.ws.call("Performance.getMetrics", {}, s)["metrics"]}
except (RuntimeError, OSError, KeyError):
self.sessions.pop(tid, None)
continue
p = self.prev.get(tid)
self.prev[tid] = m
delta = {k: (m.get(k, 0) - p.get(k, 0)) if p else 0 for k in
("TaskDuration", "ScriptDuration", "LayoutDuration")}
tabs.append({
"id": tid,
"title": (t.get("title") or "").strip() or "(untitled)",
"url": t.get("url") or "",
"task_pct": round(delta["TaskDuration"] / dt * 100, 1) if dt else 0,
"script_share": round(delta["ScriptDuration"] / delta["TaskDuration"] * 100)
if delta["TaskDuration"] > 0.0005 else 0,
"heap_mb": round(m.get("JSHeapUsedSize", 0) / 1048576, 1),
"nodes": int(m.get("Nodes", 0)),
})
procs = {}
try:
for p in self.ws.call("SystemInfo.getProcessInfo")["processInfo"]:
pid, cpu = p.get("id"), p.get("cpuTime", 0.0)
prev = self.prev_procs.get(pid)
self.prev_procs[pid] = cpu
procs[pid] = {
"type": p.get("type", ""),
"cpu": round((cpu - prev) / dt * 100, 1) if prev is not None and dt else 0.0,
"cpu_total_s": round(cpu, 1),
}
except (RuntimeError, OSError, KeyError):
pass
self.last_ok = time.time()
return {"port": self.port, "tabs": tabs, "procs": procs}
class BrowserWatcher:
"""Polls every CDP-speaking browser on the box."""
def __init__(self, extra_ports=()):
self.conns = {}
self.last = {}
self.ports = list(KNOWN_PORTS) + list(extra_ports)
self._scan = []
self._scan_ts = 0.0
def discover(self):
"""Find live CDP endpoints: configured ports + a bounded scan (cached)."""
if time.time() - self._scan_ts < 20:
return list(self._scan)
found = []
for port in self.ports:
try:
cdp.http_json(f"http://127.0.0.1:{port}/json/version", timeout=0.4)
found.append(port)
except Exception:
pass
for port in SCAN_RANGE:
try:
cdp.http_json(f"http://127.0.0.1:{port}/json/version", timeout=0.12)
found.append(port)
except Exception:
pass
self._scan, self._scan_ts = found, time.time()
return list(found)
def poll(self, dt, ports=None):
ports = ports if ports is not None else self.discover()
out = {}
for port in ports:
conn = self.conns.get(port)
if not conn:
conn = self.conns[port] = TabConn(port)
try:
out[port] = conn.poll(dt)
conn.error = ""
except Exception as exc:
conn.error = str(exc)[:120]
conn.close()
self.conns.pop(port, None)
out[port] = {"port": port, "tabs": [], "procs": {}, "error": conn.error}
for port in list(self.conns):
if port not in ports:
self.conns[port].close()
self.conns.pop(port, None)
self.last = out
return out

107
cdp.py Normal file
View file

@ -0,0 +1,107 @@
"""Minimal dependency-free CDP client (sync WebSocket) for Vitals."""
import base64
import json
import os
import socket
import struct
import urllib.request
class WS:
def __init__(self, url, timeout=3.0):
assert url.startswith("ws://")
rest = url[5:]
host_port, path = rest.split("/", 1)
host, port = host_port.split(":")
self.sock = socket.create_connection((host, int(port)), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
self.sock.sendall(req.encode())
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.sock.recv(4096)
if not chunk:
raise OSError("handshake closed")
buf += chunk
if b"101" not in buf.split(b"\r\n", 1)[0]:
raise OSError("handshake failed: " + buf[:120].decode("latin1"))
self.buf = buf.split(b"\r\n\r\n", 1)[1]
self._id = 0
def send(self, obj):
payload = json.dumps(obj).encode()
header = bytearray([0x81])
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header += struct.pack(">H", n)
else:
header.append(0x80 | 127)
header += struct.pack(">Q", n)
mask = os.urandom(4)
header += mask
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + masked)
def _read_exact(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise OSError("closed")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def recv(self):
while True:
b0, b1 = self._read_exact(2)
opcode = b0 & 0x0F
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", self._read_exact(2))[0]
elif n == 127:
n = struct.unpack(">Q", self._read_exact(8))[0]
payload = self._read_exact(n)
if opcode == 0x1:
return json.loads(payload)
if opcode == 0x8:
raise OSError("ws closed by peer")
if opcode == 0x9: # ping -> pong
self.sock.sendall(bytes([0x8A, 0x80]) + os.urandom(4))
def call(self, method, params=None, session_id=None):
self._id += 1
msg = {"id": self._id, "method": method, "params": params or {}}
if session_id:
msg["sessionId"] = session_id
self.send(msg)
while True:
resp = self.recv()
if resp.get("id") == self._id:
if "error" in resp:
raise RuntimeError(resp["error"].get("message"))
return resp.get("result", {})
def close(self):
try:
self.sock.close()
except OSError:
pass
def http_json(url, timeout=2.5):
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.load(r)
def targets(port, timeout=2.5):
return http_json(f"http://127.0.0.1:{port}/json/list", timeout)
def browser_ws(port, timeout=2.5):
return http_json(f"http://127.0.0.1:{port}/json/version", timeout)["webSocketDebuggerUrl"]

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

387
index.html Normal file
View file

@ -0,0 +1,387 @@
<!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>

27
install.sh Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Install Vitals as a systemd user service. Idempotent.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UNIT_DIR="$HOME/.config/systemd/user"
UNIT="$UNIT_DIR/vitals.service"
command -v python3 >/dev/null || { echo "python3 is required"; exit 1; }
[ -d /proc ] || { echo "this tool needs /proc (Linux)"; exit 1; }
mkdir -p "$UNIT_DIR"
sed "s|%h/Projects/vitals|$HERE|g" "$HERE/systemd/vitals.service" > "$UNIT"
systemctl --user daemon-reload
systemctl --user enable --now vitals.service
sleep 1
if systemctl --user is-active --quiet vitals.service; then
echo "Vitals is running: http://127.0.0.1:8791"
echo " logs: journalctl --user -u vitals -f"
echo " stop: systemctl --user stop vitals"
echo " disable: systemctl --user disable --now vitals"
else
echo "Vitals failed to start. Check: journalctl --user -u vitals -n 40"
exit 1
fi

14
labels.json Normal file
View file

@ -0,0 +1,14 @@
{
"apps": {
"com.brave.Browser": "Brave",
"org.keepassxc.KeePassXC": "KeePassXC"
},
"units": {
"headroom-proxy.service": "Headroom proxy"
},
"containers": {},
"commands": {
"ollama": "Ollama",
"caddy": "Caddy"
}
}

671
server.py Normal file
View file

@ -0,0 +1,671 @@
#!/usr/bin/env python3
"""
Vitals — a workload map, not a process list.
Most system monitors show you PIDs. Nobody thinks in PIDs. Vitals groups every
process under the thing a human actually named (an app, a container, an agent,
a browser instance), then reports load in both units people need:
percent of machine and percent of one core.
python3 server.py # http://127.0.0.1:8791
python3 server.py --port N
"""
import argparse
import collections
import json
import os
import re
import socket
import subprocess
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import browsers
HZ = os.sysconf("SC_CLK_TCK")
CORES = os.cpu_count() or 1
HERE = os.path.dirname(os.path.abspath(__file__))
LABELS_PATH = os.path.join(HERE, "labels.json")
SAMPLE_S = 2.0
HISTORY_N = 150 # 5 minutes at 2s
# ---------------------------------------------------------------- labels ----
DEFAULT_LABELS = {
"apps": {
"com.brave.Browser": "Brave",
"md.obsidian.Obsidian": "Obsidian",
"com.discordapp.Discord": "Discord",
"org.localsend.localsend_app": "LocalSend",
"org.telegram.desktop": "Telegram",
"us.zoom.Zoom": "Zoom",
"com.spotify.Client": "Spotify",
"com.slack.Slack": "Slack",
"org.mozilla.Thunderbird": "Thunderbird",
},
"units": {
"segundo.service": "Hermes agent · Scribe",
"headroom-proxy.service": "Headroom proxy",
"hermes-gateway.service": "Hermes gateway",
},
"containers": {
"jellyfin": "Jellyfin (media server)",
"mc-server": "Minecraft server",
},
"commands": {
"cinnamon": "Cinnamon desktop",
"nextcloud": "Nextcloud desktop",
"thunderbird": "Thunderbird",
"gnome-system-mo": "GNOME System Monitor",
"steamwebhelper": "Steam",
"1password": "1Password",
"op": "1Password CLI",
"mullvad-gui": "Mullvad VPN",
"vibe_typer.app": "Vibe Typer",
"python3": "Python",
"java": "Java",
"node": "Node",
"ollama": "Ollama",
"caddy": "Caddy",
"dockerd": "Docker daemon",
"containerd": "Docker runtime",
"localsend": "LocalSend",
"Discord": "Discord",
"chromium": "Chromium",
"brave": "Brave",
"hermes": "Hermes",
"Hermes": "Hermes desktop",
},
}
def load_labels():
labels = json.loads(json.dumps(DEFAULT_LABELS))
try:
with open(LABELS_PATH) as f:
extra = json.load(f)
for k, v in extra.items():
labels.setdefault(k, {}).update(v)
except (OSError, ValueError):
pass
return labels
LABELS = load_labels()
def prettify(s):
s = re.sub(r"^(com|org|net|io|md|us|app)\.", "", s)
s = s.replace("._app", "").replace("-", " ")
s = re.sub(r"([a-z])([A-Z])", r"\1 \2", s)
s = s.replace(".", " ").strip()
return " ".join(w.capitalize() if w.islower() else w for w in s.split())
def name_for_flatpak(app_id):
if app_id in LABELS["apps"]:
return LABELS["apps"][app_id]
return prettify(app_id)
def name_for_unit(unit):
if unit in LABELS["units"]:
return LABELS["units"][unit]
return prettify(re.sub(r"\.(service|scope|socket|timer)$", "", unit))
def name_for_cmd(comm):
if comm in LABELS["commands"]:
return LABELS["commands"][comm]
return prettify(comm) if comm else "unknown"
# ------------------------------------------------------------- /proc read ---
def read_cmdline(pid):
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
return f.read().replace(b"\0", b" ").decode("utf-8", "replace").strip()
except OSError:
return ""
def read_stat(pid):
try:
with open(f"/proc/{pid}/stat") as f:
r = f.read().rsplit(")", 1)[1].split()
return {
"ppid": int(r[1]),
"ticks": int(r[11]) + int(r[12]),
"start": int(r[19]),
}
except (OSError, IndexError, ValueError):
return None
def read_comm(pid):
try:
with open(f"/proc/{pid}/comm") as f:
return f.read().strip()
except OSError:
return ""
def read_rss_kb(pid):
try:
with open(f"/proc/{pid}/status") as f:
for line in f:
if line.startswith("VmRSS:"):
return int(line.split()[1])
except OSError:
pass
return 0
def read_cgroup(pid):
"""Return the cgroup v2 path (the 0:: line), falling back to the longest path."""
best = ""
try:
with open(f"/proc/{pid}/cgroup") as f:
for line in f:
parts = line.strip().split(":", 2)
if len(parts) != 3:
continue
path = parts[2]
if parts[0] == "0":
return path
if len(path) > len(best):
best = path
except OSError:
pass
return best
def read_threads(pid, top=8):
out = []
base = f"/proc/{pid}/task"
try:
for t in os.listdir(base):
try:
r = open(f"{base}/{t}/stat").read().rsplit(")", 1)[1].split()
out.append({"tid": t, "name": open(f"{base}/{t}/comm").read().strip(),
"ticks": int(r[11]) + int(r[12])})
except (OSError, IndexError, ValueError):
pass
except OSError:
pass
out.sort(key=lambda x: -x["ticks"])
return out[:top]
SCOPED = re.compile(r"chromium-headless/(scoped_dir\w+)")
PORT_RE = re.compile(r"--remote-debugging-port=(\d+)")
FLATPAK_SCOPE = re.compile(r"app-flatpak-([\w.\-]+?)-\d+\.scope$")
DOCKER_SCOPE = re.compile(r"docker-([0-9a-f]{64})\.scope$")
UNIT_SCOPE = re.compile(r"([\w@.\-]+\.(?:service|scope))$")
GENERIC_ROOTS = {"systemd", "init", "lightdm", "gnome-session", "cinnamon-session",
"xfce4-session", "bash", "sh", "zsh", "dash", "dbus-daemon",
"dbus-broker", "gdm-session-wor", "gnome-shell", "sddm", "startx",
"xinit", "user@1000.service", "session-c", "gnome-terminal-"}
INTERPRETERS = {"python3", "python", "node", "java", "bash", "sh", "perl", "ruby", "op"}
def is_generic(comm):
"""comm is truncated to 15 chars by the kernel, so compare on a prefix."""
if not comm:
return False
for g in GENERIC_ROOTS:
if comm.startswith(g[:15]) or g.startswith(comm[:15]):
return True
return False
def st_ppid_is_kernel(ppid):
"""Kernel threads are children of kthreadd (pid 2)."""
return ppid == 2
def derive_name(comm, cmd):
"""A human name for a loose process, preferring the script/app it runs."""
if comm in INTERPRETERS and cmd:
for tok in cmd.split()[1:]:
if tok.startswith("-"):
continue
base = os.path.basename(tok)
if base and "." in base:
return prettify(base.rsplit(".", 1)[0].replace("_", " ").replace("-", " "))
break
return name_for_cmd(comm)
class Sampler(threading.Thread):
daemon = True
def __init__(self):
super().__init__(name="vitals-sampler")
self.lock = threading.Lock()
self.prev = {}
self.pid_cpu = {}
self.meta = {} # pid -> (start, comm, cmdline, cgroup) cached
self.containers = {} # full cid -> (name, image)
self.windows = collections.defaultdict(list) # pid -> [titles]
self.ports = collections.defaultdict(list) # pid -> [ports]
self.history = collections.deque(maxlen=HISTORY_N)
self.latest = {"workloads": [], "flags": [], "t": 0}
self.self_pid = os.getpid()
self.self_cost = 0.0
self.watcher = browsers.BrowserWatcher()
# -- external inventories -------------------------------------------
def refresh_containers(self):
try:
out = subprocess.run(
["docker", "ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}"],
capture_output=True, text=True, timeout=4).stdout
self.containers = {l.split("\t")[0]: (l.split("\t")[1], l.split("\t")[2])
for l in out.splitlines() if l.count("\t") >= 2}
except (OSError, subprocess.SubprocessError):
pass
def refresh_windows(self):
wins = collections.defaultdict(list)
try:
out = subprocess.run(["wmctrl", "-l"], capture_output=True, text=True, timeout=4).stdout
except (OSError, subprocess.SubprocessError):
out = ""
for line in out.splitlines():
parts = line.split(None, 3)
if len(parts) < 4:
continue
wid, title = parts[0], parts[3]
try:
pr = subprocess.run(["xprop", "-id", wid, "_NET_WM_PID"],
capture_output=True, text=True, timeout=2).stdout
pid = int(pr.rsplit(" ", 1)[-1].strip())
except (OSError, subprocess.SubprocessError, ValueError):
pid = 0
if pid > 2:
wins[pid].append(title)
self.windows = wins
def refresh_ports(self):
ports = collections.defaultdict(list)
try:
out = subprocess.run(["ss", "-ltnp"], capture_output=True, text=True, timeout=4).stdout
except (OSError, subprocess.SubprocessError):
out = ""
for line in out.splitlines()[1:]:
m = re.search(r":(\d+)\s.*pid=(\d+)", line)
if m:
ports[int(m.group(2))].append(int(m.group(1)))
self.ports = ports
# -- classification --------------------------------------------------
def classify(self, pid, comm, cmd, cgroup, ppid):
"""Return (workload_key, workload_name, role, extra)."""
if pid == self.self_pid or "vitals/server.py" in cmd:
return "vitals", "Vitals (this monitor)", "monitor", {}
if "chromium-headless" in cmd:
m = SCOPED.search(cmd)
inst = m.group(1) if m else "instance"
p = PORT_RE.search(cmd)
role = "headless browser"
if "--type=gpu-process" in cmd:
role = "gpu"
elif "--type=renderer" in cmd:
role = "renderer"
return "headless", "Headless browser automation", role, {"instance": inst, "port": p.group(1) if p else ""}
m = DOCKER_SCOPE.search(cgroup)
if m:
entry = self.containers.get(m.group(1))
if entry:
return f"docker:{entry[0]}", f"{entry[0]} · container", "container", {"image": entry[1]}
return f"docker:{m.group(1)[:12]}", f"container {m.group(1)[:12]}", "container", {}
m = FLATPAK_SCOPE.search(cgroup)
if m:
app = m.group(1)
name = name_for_flatpak(app)
role = "app"
if "--type=gpu-process" in cmd:
role = "gpu"
elif "--type=renderer" in cmd:
role = "tab renderer"
elif "--type=utility" in cmd:
u = re.search(r"--utility-sub-type=[\w.]+\.([\w]+)", cmd)
role = (u.group(1) if u else "utility").lower()
elif "--type=browser" in cmd or "--type=" not in cmd:
role = "main"
extra = {"app_id": app}
if role in ("tab renderer", "utility", "video_captureservice"):
th = set(t["name"] for t in read_threads(pid, 30))
if {"WebRTC_Signalin", "WebRTC_W_and_N"} & th or "VideoFrameCompo" in th:
role = "call (WebRTC)"
extra["media"] = True
return f"app:{app}", name, role, extra
m = UNIT_SCOPE.search(cgroup)
if m and "app-" not in cgroup and not re.match(r"(session-[\w]+|init)\.scope$", m.group(1)):
unit = m.group(1)
if "hermes" in cmd:
prof = re.search(r"-p[ =](\w+)", cmd)
sub = "gateway" if "gateway" in cmd else (prof.group(1) if prof else "session")
return f"agents:{unit}", f"Hermes agent · {sub}", "agent", {"unit": unit}
return f"unit:{unit}", name_for_unit(unit), "service", {"unit": unit}
if "hermes" in cmd or comm in ("hermes", "Hermes"):
prof = re.search(r"-p[ =](\w+)", cmd)
sub = "gateway" if "gateway" in cmd else (prof.group(1).capitalize() if prof else "session")
# each chat session is its own thing: key it on its root process
root, guard = pid, 0
while guard < 24:
st = self.meta.get(root)
if not st or not st.get("ppid") or st["ppid"] in (1, 0):
break
root = st["ppid"]
guard += 1
suffix = "" if sub == "gateway" else f":{root}"
return f"agents:{sub.lower()}{suffix}", f"Hermes agent · {sub}", "agent", {"root": root}
if st_ppid_is_kernel(ppid) or comm.startswith(("kworker", "ksoftirqd", "migration", "rcu_",
"kthreadd", "kcompactd", "kswapd", "jbd2/")):
return "kernel", "Kernel", "kernel", {}
# fall back to the top-most ancestor below init: that is the "app"
root, guard = pid, 0
while guard < 24:
st = self.meta.get(root)
if not st or not st.get("ppid") or st["ppid"] in (1, 0):
break
if st["ppid"] == 2:
break
root = st["ppid"]
guard += 1
root_comm = read_comm(root) or comm
if is_generic(root_comm):
# a generic parent is not an identity: name the process itself
return f"cmd:{comm}", derive_name(comm, cmd), "process", {}
return f"proc:{root}", name_for_cmd(root_comm), "process", {"root": root}
# -- one sample -------------------------------------------------------
def sample(self):
t0 = time.time()
now = time.time()
up = float(open("/proc/uptime").read().split()[0])
cur = {}
for d in os.scandir("/proc"):
if not d.name.isdigit():
continue
pid = int(d.name)
st = read_stat(pid)
if not st:
continue
cur[pid] = st
cached = self.meta.get(pid)
if not cached or cached["start"] != st["start"]:
self.meta[pid] = {
"start": st["start"], "comm": read_comm(pid),
"cmd": read_cmdline(pid), "cgroup": read_cgroup(pid),
"ppid": st["ppid"], "rss": read_rss_kb(pid),
}
self.meta[pid]["ppid"] = st["ppid"]
dt = max(0.2, now - (self.latest.get("t") or now))
workloads = {}
total_ticks = 0.0
pid_cpu = {}
pid_key = {}
for pid, st in cur.items():
prev = self.prev.get(pid)
if not prev:
continue
pct = max(0.0, (st["ticks"] - prev) / HZ / dt * 100)
pid_cpu[pid] = pct
total_ticks += pct
m = self.meta[pid]
key, name, role, extra = self.classify(pid, m["comm"], m["cmd"], m["cgroup"], st["ppid"])
pid_key[pid] = key
w = workloads.setdefault(key, {
"key": key, "name": name, "cpu": 0.0, "rss": 0, "procs": 0,
"age_h": 9e9, "pids": [], "roles": collections.Counter(),
"instances": set(), "flags": set(),
"inst_cpu": collections.Counter(), "inst_port": {},
"inst_age": {},
})
w["cpu"] += pct
w["rss"] += m["rss"]
w["procs"] += 1
w["age_h"] = min(w["age_h"], (up - m["start"] / HZ) / 3600)
w["roles"][role] += pct
w["pids"].append(pid)
if extra.get("instance"):
w["instances"].add(extra["instance"])
w["inst_cpu"][extra["instance"]] += pct
this_age = (up - m["start"] / HZ) / 3600
w["inst_age"][extra["instance"]] = min(
w["inst_age"].get(extra["instance"], 9e9), this_age)
if extra.get("port"):
w["inst_port"][str(extra["port"])] = extra["instance"]
if "headless" == key and st["ppid"] == 1 and w["age_h"] > 1:
w["flags"].add("leaked")
if role == "call (WebRTC)" and pct > 25:
w["flags"].add("on a call")
# ---- browser layer: per-tab accounting ---------------------------
# CDP is the expensive part; poll it every other sample.
bdt = now - getattr(self, "last_browser_ts", 0)
if bdt >= 4.0:
ports = self.watcher.discover()
browser = self.watcher.poll(bdt, ports) if ports else {}
self.last_browser_ts = now
else:
browser = self.watcher.last
self.prev = {pid: st["ticks"] for pid, st in cur.items()}
self.pid_cpu = pid_cpu
self.meta = {pid: v for pid, v in self.meta.items() if pid in cur}
total_busy = total_ticks / 100.0
self.self_cost = next((w["cpu"] for w in workloads.values() if w["key"] == "vitals"), 0.0)
out = []
tabs_flat = []
hist = list(self.history)[-60:]
for w in workloads.values():
w["age_h"] = 0 if w["age_h"] > 8e8 else w["age_h"]
tabs, tab_cpu, verdict = [], 0.0, ""
for port, res in browser.items():
owner = [p for p in res.get("procs", {}) if pid_key.get(p) == w["key"]]
if not owner:
continue
inst = w["inst_port"].get(str(port), "")
for t in res.get("tabs", []):
tab_cpu += t["task_pct"] / 100
tabs.append({**t, "port": port, "instance": inst,
"instance_age": round(w["inst_age"].get(inst, 0), 1),
"proc_cores": round(sum(
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"])
w_cores = w["cpu"] / 100.0
if tabs:
if w_cores - tab_cpu > 0.5 and tab_cpu < w_cores * 0.4:
verdict = (f"pages are idle ({tab_cpu:.2f} cores of page work) — "
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)"
else:
verdict = ""
inst_detail = []
for inst, cpu in w["inst_cpu"].most_common(12):
inst_detail.append({
"instance": inst, "cpu": round(cpu / 100, 2),
"port": next((p for p, i in w["inst_port"].items() if i == 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), ""),
})
for t in tabs:
tabs_flat.append({"browser": w["name"], "key": w["key"], **t})
out.append({
"key": w["key"], "name": w["name"],
"cpu": round(w["cpu"] / 100, 2),
"pct": round(w["cpu"] / (CORES * 100) * 100, 1),
"rss_mb": round(w["rss"] / 1024),
"procs": w["procs"],
"age_h": round(w["age_h"], 1),
"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],
"verdict": verdict,
"instances_detail": inst_detail,
"has_cdp": bool(tabs),
"series": [round(h.get(w["key"], 0.0), 2) for _, h in hist],
})
out.sort(key=lambda x: -x["cpu"])
tabs_flat.sort(key=lambda t: -t["task_pct"])
flags = [{"key": w["key"], "name": w["name"], "flag": f, "cpu": w["cpu"]}
for w in out for f in w["flags"]]
browsers_seen = [{"port": p, "tabs": len(r.get("tabs", [])),
"error": r.get("error", ""),
"name": next((w["name"] for w in out
if any(t.get("port") == p for t in w.get("tabs", []))), "")
} for p, r in browser.items()]
with self.lock:
self.history.append((now, {w["key"]: w["cpu"] for w in out}))
self.latest = {
"t": now, "cores": CORES, "host": socket.gethostname(),
"total_busy": round(total_busy, 2),
"total_pct": round(total_busy / CORES * 100),
"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_cost": round(self.self_cost / 100, 2),
"sample_ms": round((time.time() - t0) * 1000),
}
return self.latest
def detail(self, key):
with self.lock:
hist = list(self.history)
up = float(open("/proc/uptime").read().split()[0])
procs = []
for pid, m in self.meta.items():
k, name, role, extra = self.classify(pid, m["comm"], m["cmd"], m["cgroup"], m["ppid"])
if k != key:
continue
procs.append({
"pid": pid, "role": role, "rss_mb": round(m["rss"] / 1024),
"cpu": round(getattr(self, "pid_cpu", {}).get(pid, 0.0) / 100, 2),
"age_h": round((up - m["start"] / HZ) / 3600, 1),
"cmd": m["cmd"][:400], "cgroup": m["cgroup"],
"windows": self.windows.get(pid, []),
"ports": sorted(set(self.ports.get(pid, []))),
"extra": extra,
"threads": read_threads(pid, 6),
})
procs.sort(key=lambda p: (-p["cpu"], -p["rss_mb"]))
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:]}
def run(self):
self.refresh_containers()
last_cont = last_win = last_port = 0
while True:
try:
if time.time() - last_cont > 20:
self.refresh_containers(); last_cont = time.time()
if time.time() - last_win > 12:
self.refresh_windows(); last_win = time.time()
if time.time() - last_port > 10:
self.refresh_ports(); last_port = time.time()
self.sample()
except Exception as exc: # never die mid-flight
print("sample error:", exc, flush=True)
time.sleep(SAMPLE_S)
SAMPLER = Sampler()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def _send(self, body, ctype="application/json", code=200):
data = body if isinstance(body, bytes) else body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
u = urlparse(self.path)
if u.path in ("/", "/index.html"):
try:
with open(os.path.join(HERE, "index.html"), "rb") as f:
return self._send(f.read(), "text/html; charset=utf-8")
except OSError:
return self._send("index.html missing", "text/plain", 500)
if u.path == "/api/snapshot":
with SAMPLER.lock:
return self._send(json.dumps(SAMPLER.latest))
if u.path == "/api/detail":
key = parse_qs(u.query).get("key", [""])[0]
if not key:
return self._send('{"error":"key required"}', code=400)
return self._send(json.dumps(SAMPLER.detail(key)))
return self._send("not found", "text/plain", 404)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=int(os.environ.get("VITALS_PORT", 8791)))
ap.add_argument("--host", default="127.0.0.1")
args = ap.parse_args()
SAMPLER.start()
time.sleep(1.2)
try:
srv = ThreadingHTTPServer((args.host, args.port), Handler)
except OSError as exc:
if exc.errno == 98:
print(f"Vitals: port {args.port} is already taken. Probing it…", flush=True)
try:
with urllib.request.urlopen(
f"http://{args.host}:{args.port}/api/snapshot", timeout=3) as r:
if r.status == 200 and "workloads" in r.read(400).decode("utf-8", "replace"):
print(f"Vitals is already running on http://{args.host}:{args.port} "
f"— nothing to do.", flush=True)
return
except Exception:
pass
print(f"Vitals: something else owns port {args.port}. Try --port N.", flush=True)
return
raise
print(f"Vitals on http://{args.host}:{args.port} ({CORES} cores)", flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

19
systemd/vitals.service Normal file
View file

@ -0,0 +1,19 @@
[Unit]
Description=Vitals — workload and browser-tab monitor
Documentation=https://forge.binaryranch.com/sastian/vitals
After=graphical-session.target network.target
PartOf=graphical-session.target
[Service]
Type=simple
WorkingDirectory=%h/Projects/vitals
ExecStart=/usr/bin/python3 %h/Projects/vitals/server.py --port 8791
Restart=on-failure
RestartSec=3
# a monitor must never be the reason a machine is slow
Nice=10
CPUQuota=25%
MemoryMax=256M
[Install]
WantedBy=default.target

41
tests/test_identity.py Normal file
View file

@ -0,0 +1,41 @@
"""Unit tests for the identity layer. No server, no root, no browser needed."""
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 TestNaming(unittest.TestCase):
def test_flatpak_ids_become_product_names(self):
# known apps come from labels.json; unknown ones get prettified
self.assertEqual(server.name_for_flatpak("com.brave.Browser"), "Brave")
self.assertEqual(server.name_for_flatpak("org.localsend.localsend_app"), "LocalSend")
self.assertEqual(server.name_for_flatpak("com.example.Thing"), "Example Thing")
def test_generic_parents_are_not_identities(self):
# comm is truncated to 15 chars by the kernel, so this is what we see
self.assertTrue(server.is_generic("cinnamon-sessio"))
self.assertTrue(server.is_generic("systemd"))
self.assertTrue(server.is_generic("dbus-daemon"))
self.assertFalse(server.is_generic("chromium"))
self.assertFalse(server.is_generic("brave"))
def test_interpreter_processes_are_named_by_their_script(self):
self.assertEqual(
server.derive_name("python3", "python3 /home/a/heartbeat.py --serve"),
"Heartbeat")
self.assertEqual(
server.derive_name("python3", "python3 -u /opt/x/vitals_server.py"),
"Vitals Server")
def test_own_cgroup_is_readable(self):
path = server.read_cgroup(os.getpid())
self.assertTrue(path.startswith("/"), path)
if __name__ == "__main__":
unittest.main()