commit 9d065153b0a321694669ee7d49c7f41e733f4303 Author: Christian Sass Date: Mon Oct 19 16:42:54 2026 -0500 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21fe6f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.log +.DS_Store +.vitals-* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c18605 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e3a9d3 --- /dev/null +++ b/README.md @@ -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//fd` and `/proc//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). diff --git a/browsers.py b/browsers.py new file mode 100644 index 0000000..3a97e56 --- /dev/null +++ b/browsers.py @@ -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 diff --git a/cdp.py b/cdp.py new file mode 100644 index 0000000..14e013a --- /dev/null +++ b/cdp.py @@ -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"] + diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..c48d243 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..486c6ac --- /dev/null +++ b/index.html @@ -0,0 +1,387 @@ + + + + + +Vitals — workload map + + + +
+ +
+
VITALSworkload map
+
connecting…
+
+ +
+
–of machine busy
+
–cores in use
+
–largest workload
+
+ +
+
+
+ +

Workloads

+
+ + + +
+ +
+
+
workload
% machine
cores
+
RAM
age
trend
+
+
+
+ +

Browser tabs

+ +
+
tab
browser
page work
heap
+
+
+ +
+ % 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 –. +
+
+ + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1c33ac9 --- /dev/null +++ b/install.sh @@ -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 diff --git a/labels.json b/labels.json new file mode 100644 index 0000000..75e950a --- /dev/null +++ b/labels.json @@ -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" + } +} diff --git a/server.py b/server.py new file mode 100644 index 0000000..31613ec --- /dev/null +++ b/server.py @@ -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() diff --git a/systemd/vitals.service b/systemd/vitals.service new file mode 100644 index 0000000..bdec532 --- /dev/null +++ b/systemd/vitals.service @@ -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 diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..f2c81b0 --- /dev/null +++ b/tests/test_identity.py @@ -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()