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:
commit
9d065153b0
12 changed files with 1599 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vitals-*
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
151
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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
154
browsers.py
Normal 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
107
cdp.py
Normal 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
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
387
index.html
Normal file
387
index.html
Normal 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=>({"&":"&","<":"<",">":">",'"':"""}[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
27
install.sh
Executable 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
14
labels.json
Normal 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
671
server.py
Normal 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
19
systemd/vitals.service
Normal 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
41
tests/test_identity.py
Normal 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()
|
||||
Loading…
Reference in a new issue