Live UI: stop the page from jumping, and put Vitals in the bookmark bar
The page rebuilt its whole header, bar and row list twice a second, so numbers snapped, columns resized as digits changed, and rows swapped places over a hundredth of a core. - header values are tabular-width and tweened toward the newest sample - machine-bar segments are keyed and reordered by flex order, so widths animate instead of being recreated mid-transition - rows are keyed DOM nodes updated in place, sorted on a 0.1-core grid with the previous order as tiebreak, with hysteresis at the idle threshold - tabs, banner and detail panes only re-render when their data changes - poll every 700ms and drop repeat frames from the 2s sampler bookmark.py puts a Vitals bookmark and a popup bookmarklet in the browser's bookmark bar. It recomputes Chromium's bookmark checksum (MD5 over the canonical node serialisation) and verifies it after writing, because a file that fails that check leaves the browser showing an empty bar while the file on disk still looks perfect. Refuses while the browser holds the profile, keeps a timestamped backup, idempotent. install.sh runs it. Co-developed-with: CLU
This commit is contained in:
parent
f086afc030
commit
95c1c1d3eb
5 changed files with 754 additions and 85 deletions
38
README.md
38
README.md
|
|
@ -85,6 +85,44 @@ silently orphaned process.
|
|||
|
||||
Options: `--port N`, `--host ADDR`, `VITALS_PORT` env var.
|
||||
|
||||
### The bookmark
|
||||
|
||||
`install.sh` also puts Vitals in your browser's bookmark bar, as a bookmark and
|
||||
as a popup bookmarklet that opens it in its own window. A tool you have to
|
||||
remember the URL of does not get used.
|
||||
|
||||
Chromium keeps the bookmark model in memory and rewrites the file on its next
|
||||
save, so the browser has to be closed while that write happens. If it is running,
|
||||
install prints the one command to run afterwards instead of failing:
|
||||
|
||||
```bash
|
||||
python3 bookmark.py --install # add bookmark + popup
|
||||
python3 bookmark.py --install --wait 600 # wait for the browser to exit
|
||||
python3 bookmark.py --status # is it there, is the file sane
|
||||
python3 bookmark.py --remove # take them back out
|
||||
```
|
||||
|
||||
The tool recomputes Chromium's bookmark checksum (MD5 over a canonical
|
||||
serialisation of every node) and verifies it after writing, because a file that
|
||||
fails that check leaves the browser showing an empty bar while the file on disk
|
||||
still looks perfect. It keeps a timestamped backup of the file it edited.
|
||||
|
||||
## Live updates
|
||||
|
||||
The sampler runs every 2 seconds, because one sample costs about 400 ms of CPU
|
||||
and a monitor that eats the machine is the joke writing itself. The page polls
|
||||
faster than that and eases between frames, so the numbers move continuously
|
||||
instead of snapping twice a second:
|
||||
|
||||
- header values are tabular-width and tweened toward the newest sample
|
||||
- machine-bar segments are keyed and reordered with flex `order`, so their
|
||||
widths actually animate instead of being recreated mid-transition
|
||||
- rows are keyed DOM nodes updated in place, sorted on a 0.1-core grid with the
|
||||
previous order as tiebreak, so a row does not jump position over a hundredth
|
||||
of a core
|
||||
- a row appears at 0.05 cores but only leaves after it stays under 0.02, so
|
||||
nothing flickers in and out at the threshold
|
||||
|
||||
## Getting browser tabs
|
||||
|
||||
Tabs come from the DevTools Protocol, which a browser only exposes if it was
|
||||
|
|
|
|||
363
bookmark.py
Normal file
363
bookmark.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Put Vitals in the browser's bookmark bar.
|
||||
|
||||
Chromium-family browsers (Brave, Chrome, Chromium) store bookmarks as a JSON
|
||||
file with an MD5 checksum over a canonical serialisation of every node. If the
|
||||
checksum does not match, the browser silently falls back to Bookmarks.bak and
|
||||
the edit disappears, so this tool recomputes it and refuses to write a file it
|
||||
cannot verify.
|
||||
|
||||
Checksum algorithm (components/bookmarks/browser/bookmark_codec.cc):
|
||||
for each root folder, in order bookmark_bar, other, synced:
|
||||
feed the root itself as a folder node
|
||||
then every descendant, depth first, in order
|
||||
folder node : id + name (UTF-16LE bytes) + b"folder"
|
||||
url node : id + name (UTF-16LE bytes) + b"url" + url (UTF-8)
|
||||
|
||||
Verified by reproducing the stored checksum of a real 13.8 MB profile file.
|
||||
|
||||
Safe by construction: refuses while the browser owns the profile, writes through
|
||||
a temp file with an atomic rename, keeps a timestamped backup, and re-reads the
|
||||
file it just wrote to confirm the checksum lands.
|
||||
|
||||
Usage:
|
||||
python3 bookmark.py --install # add Vitals + Vitals popup
|
||||
python3 bookmark.py --install --wait 600 # wait up to 10 min for browser exit
|
||||
python3 bookmark.py --status # what is in the bar right now
|
||||
python3 bookmark.py --remove # take them back out
|
||||
python3 bookmark.py --install --profile-dir /path/to/Profile
|
||||
python3 bookmark.py --install --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
VITALS_URL = "http://127.0.0.1:8791/"
|
||||
BOOKMARKLET = (
|
||||
"javascript:(function(){window.open('http://127.0.0.1:8791/',"
|
||||
"'vitals','width=1500,height=950,menubar=no,toolbar=no,location=no,"
|
||||
"status=no');})();void(0);"
|
||||
)
|
||||
ROOTS = ("bookmark_bar", "other", "synced")
|
||||
WINDOWS_EPOCH_OFFSET = 11644473600 # seconds between 1601-01-01 and 1970-01-01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- checksum
|
||||
def _u16(s: str) -> bytes:
|
||||
return s.encode("utf-16-le")
|
||||
|
||||
|
||||
def _feed_url(h, node) -> None:
|
||||
h.update(str(node["id"]).encode())
|
||||
h.update(_u16(node.get("name") or ""))
|
||||
h.update(b"url")
|
||||
h.update((node.get("url") or "").encode("utf-8"))
|
||||
|
||||
|
||||
def _feed_folder(h, node) -> None:
|
||||
h.update(str(node["id"]).encode())
|
||||
h.update(_u16(node.get("name") or ""))
|
||||
h.update(b"folder")
|
||||
|
||||
|
||||
def _walk(h, node) -> None:
|
||||
if node.get("type") == "url":
|
||||
_feed_url(h, node)
|
||||
else:
|
||||
_feed_folder(h, node)
|
||||
for child in node.get("children", []):
|
||||
_walk(h, child)
|
||||
|
||||
|
||||
def checksum(doc) -> str:
|
||||
h = hashlib.md5()
|
||||
for key in ROOTS:
|
||||
root = doc["roots"].get(key)
|
||||
if not root:
|
||||
continue
|
||||
_feed_folder(h, root)
|
||||
for child in root.get("children", []):
|
||||
_walk(h, child)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- discovery
|
||||
def candidate_profiles():
|
||||
home = os.path.expanduser("~")
|
||||
pats = [
|
||||
# Flatpak builds keep their profile inside the sandbox.
|
||||
f"{home}/.var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser/Default",
|
||||
f"{home}/.var/app/com.google.Chrome/config/google-chrome/Default",
|
||||
f"{home}/.var/app/org.chromium.Chromium/config/chromium/Default",
|
||||
# Native builds.
|
||||
f"{home}/.config/BraveSoftware/Brave-Browser/Default",
|
||||
f"{home}/.config/google-chrome/Default",
|
||||
f"{home}/.config/chromium/Default",
|
||||
# Chromium-family shells.
|
||||
f"{home}/.config/microsoft-edge/Default",
|
||||
f"{home}/.config/vivaldi/Default",
|
||||
]
|
||||
out = []
|
||||
for p in pats:
|
||||
if os.path.isfile(os.path.join(p, "Bookmarks")):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def profile_in_use(profile_dir: str) -> bool:
|
||||
"""True when a browser currently owns this profile.
|
||||
|
||||
Chromium takes a SingletonLock in the user data dir (the parent of the
|
||||
profile dir). Reading the bookmarks file under a live browser is pointless:
|
||||
the in-memory model overwrites it on the next save.
|
||||
"""
|
||||
user_data_dir = os.path.dirname(profile_dir.rstrip("/"))
|
||||
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||
if os.path.exists(os.path.join(user_data_dir, name)):
|
||||
return True
|
||||
# Also catch a browser pointed at this profile with an explicit flag.
|
||||
needle = os.path.abspath(profile_dir)
|
||||
try:
|
||||
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
||||
except OSError:
|
||||
return False
|
||||
for pid in pids:
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
cmd = f.read().replace(b"\x00", b" ").decode("utf8", "replace")
|
||||
except OSError:
|
||||
continue
|
||||
if "--type=" in cmd:
|
||||
continue
|
||||
if needle in cmd and any(b in cmd for b in ("brave", "chrome", "chromium", "edge", "vivaldi")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def max_id(doc) -> int:
|
||||
top = 0
|
||||
|
||||
def walk(node):
|
||||
nonlocal top
|
||||
try:
|
||||
top = max(top, int(node.get("id", 0)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for child in node.get("children", []):
|
||||
walk(child)
|
||||
|
||||
for key in ROOTS:
|
||||
if doc["roots"].get(key):
|
||||
walk(doc["roots"][key])
|
||||
return top
|
||||
|
||||
|
||||
def now_micros() -> str:
|
||||
return str(int((time.time() + WINDOWS_EPOCH_OFFSET) * 1_000_000))
|
||||
|
||||
|
||||
def find_ours(doc):
|
||||
"""Return (vitals_node, bookmarklet_node) currently in the bar, if any."""
|
||||
plain = bm = None
|
||||
|
||||
def walk(node):
|
||||
nonlocal plain, bm
|
||||
if node.get("type") == "url":
|
||||
url = node.get("url") or ""
|
||||
if url == VITALS_URL:
|
||||
plain = node
|
||||
elif url.startswith("javascript:") and "8791" in url:
|
||||
bm = node
|
||||
for child in node.get("children", []):
|
||||
walk(child)
|
||||
|
||||
for key in ROOTS:
|
||||
if doc["roots"].get(key):
|
||||
walk(doc["roots"][key])
|
||||
return plain, bm
|
||||
|
||||
|
||||
def make_node(node_id: int, name: str, url: str) -> dict:
|
||||
stamp = now_micros()
|
||||
return {
|
||||
"date_added": stamp,
|
||||
"date_last_used": "0",
|
||||
"guid": str(uuid.uuid4()),
|
||||
"id": str(node_id),
|
||||
"name": name,
|
||||
"type": "url",
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- operations
|
||||
def load(profile_dir):
|
||||
path = os.path.join(profile_dir, "Bookmarks")
|
||||
with open(path, encoding="utf8") as f:
|
||||
raw = f.read()
|
||||
return path, raw, json.loads(raw)
|
||||
|
||||
|
||||
def save(path, doc, raw_before) -> None:
|
||||
doc["checksum"] = checksum(doc)
|
||||
doc.pop("checksum_sha256", None) # browser rewrites it; absent is valid
|
||||
text = json.dumps(doc, indent=3, sort_keys=True, ensure_ascii=True)
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup = f"{path}.pre-vitals-{stamp}.bak"
|
||||
shutil.copy2(path, backup)
|
||||
tmp = f"{path}.vitals.tmp"
|
||||
with open(tmp, "w", encoding="utf8") as f:
|
||||
f.write(text)
|
||||
shutil.copystat(path, tmp)
|
||||
os.replace(tmp, path)
|
||||
print(f" backup: {backup}")
|
||||
|
||||
# Verify what actually landed on disk.
|
||||
with open(path, encoding="utf8") as f:
|
||||
check = json.load(f)
|
||||
if checksum(check) != check.get("checksum"):
|
||||
raise SystemExit("REFUSING: checksum did not verify after write")
|
||||
if len(raw_before) and not check["roots"].get("bookmark_bar"):
|
||||
raise SystemExit("REFUSING: bookmark bar missing after write")
|
||||
|
||||
|
||||
def install(profile_dir, dry_run=False, name="Vitals", popup_name="Vitals popup") -> int:
|
||||
path, raw, doc = load(profile_dir)
|
||||
plain, bm = find_ours(doc)
|
||||
next_id = max_id(doc) + 1
|
||||
added = []
|
||||
|
||||
if plain is None:
|
||||
doc["roots"]["bookmark_bar"].setdefault("children", []).append(
|
||||
make_node(next_id, name, VITALS_URL)
|
||||
)
|
||||
next_id += 1
|
||||
added.append(name)
|
||||
elif plain.get("name") != name:
|
||||
plain["name"] = name
|
||||
added.append(f"{name} (renamed)")
|
||||
|
||||
if bm is None:
|
||||
doc["roots"]["bookmark_bar"].setdefault("children", []).append(
|
||||
make_node(next_id, popup_name, BOOKMARKLET)
|
||||
)
|
||||
added.append(popup_name)
|
||||
|
||||
if not added:
|
||||
print(" already installed, nothing to do")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
probe = json.loads(raw)
|
||||
probe["checksum"] = checksum(doc)
|
||||
print(f" dry run: would add {added} | new checksum {probe['checksum']}")
|
||||
return 0
|
||||
|
||||
print(f" profile: {profile_dir}")
|
||||
print(f" adding: {', '.join(added)}")
|
||||
save(path, doc, raw)
|
||||
print(f" checksum: {doc['checksum']} verified on disk")
|
||||
return 0
|
||||
|
||||
|
||||
def remove(profile_dir, dry_run=False) -> int:
|
||||
path, raw, doc = load(profile_dir)
|
||||
plain, bm = find_ours(doc)
|
||||
if plain is None and bm is None:
|
||||
print(" nothing to remove")
|
||||
return 0
|
||||
removed = []
|
||||
for key in ROOTS:
|
||||
root = doc["roots"].get(key)
|
||||
if not root:
|
||||
continue
|
||||
keep = []
|
||||
for child in root.get("children", []):
|
||||
if child is plain or child is bm:
|
||||
removed.append(child.get("name"))
|
||||
else:
|
||||
keep.append(child)
|
||||
root["children"] = keep
|
||||
if dry_run:
|
||||
print(f" dry run: would remove {removed}")
|
||||
return 0
|
||||
save(path, doc, raw)
|
||||
print(f" removed: {removed}")
|
||||
return 0
|
||||
|
||||
|
||||
def status(profile_dir) -> int:
|
||||
_, _, doc = load(profile_dir)
|
||||
plain, bm = find_ours(doc)
|
||||
bar = doc["roots"]["bookmark_bar"]
|
||||
print(f" profile : {profile_dir}")
|
||||
print(f" bar : {len(bar.get('children', []))} items")
|
||||
print(f" Vitals : {plain.get('name') + ' -> ' + plain['url'] if plain else 'not present'}")
|
||||
print(f" popup : {bm.get('name') if bm else 'not present'}")
|
||||
stored = doc.get("checksum", "")
|
||||
good = stored == checksum(doc)
|
||||
print(f" checksum: {'valid' if good else 'MISMATCH'}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Install Vitals into the bookmark bar")
|
||||
ap.add_argument("--install", action="store_true")
|
||||
ap.add_argument("--remove", action="store_true")
|
||||
ap.add_argument("--status", action="store_true")
|
||||
ap.add_argument("--profile-dir", default=None)
|
||||
ap.add_argument("--name", default="Vitals")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--wait", type=int, default=0,
|
||||
help="seconds to wait for the browser to exit before giving up")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not (args.install or args.remove or args.status):
|
||||
ap.print_help()
|
||||
return 2
|
||||
|
||||
profiles = [args.profile_dir] if args.profile_dir else candidate_profiles()
|
||||
if not profiles:
|
||||
print("no Chromium-family profile found; pass --profile-dir")
|
||||
return 1
|
||||
|
||||
targets = []
|
||||
for p in profiles:
|
||||
if not os.path.isfile(os.path.join(p, "Bookmarks")):
|
||||
print(f" skip {p}: no Bookmarks file")
|
||||
continue
|
||||
waited = 0
|
||||
while profile_in_use(p) and waited < args.wait:
|
||||
if waited == 0:
|
||||
print(f" browser is running on {p}; waiting up to {args.wait}s")
|
||||
time.sleep(2)
|
||||
waited += 2
|
||||
if profile_in_use(p):
|
||||
print(f" skip {p}: browser holds the profile lock. Close the browser "
|
||||
f"and re-run, or pass --wait N.")
|
||||
continue
|
||||
targets.append(p)
|
||||
|
||||
if not targets:
|
||||
return 1
|
||||
|
||||
for p in targets:
|
||||
if args.status:
|
||||
status(p)
|
||||
elif args.remove:
|
||||
remove(p, args.dry_run)
|
||||
else:
|
||||
install(p, args.dry_run, name=args.name)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
274
index.html
274
index.html
|
|
@ -115,6 +115,19 @@ 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)}
|
||||
|
||||
/* --- live-update stability: nothing moves unless its own value moved --- */
|
||||
.stat b{font-variant-numeric:tabular-nums}
|
||||
.stat .v{display:inline-block;text-align:right}
|
||||
.stat.busy .v{min-width:2.2ch}
|
||||
.stat.cores .v{min-width:4.4ch}
|
||||
.machine div{transition:width .9s linear}
|
||||
.legend span{white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.legend span em{font-style:normal;display:inline-block;min-width:3.1em;text-align:right}
|
||||
.rows{display:flex;flex-direction:column}
|
||||
.row .bar i{transition:width .9s linear}
|
||||
.num{font-variant-numeric:tabular-nums}
|
||||
.stat, .legend span, .tabs .tr{will-change:auto}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -126,9 +139,9 @@ button:hover{border-color:#37424d;color:var(--fg)}
|
|||
</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 class="stat busy"><b id="busy"><span class="v">–</span><small>%</small></b><i>of machine busy</i></div>
|
||||
<div class="stat cores"><b id="cores"><span class="v">–</span><small id="coresmax"> / –</small></b><i>cores in use</i></div>
|
||||
<div class="stat"><b id="topname" style="font-size:22px">–</b><i id="topsub">largest workload</i></div>
|
||||
</div>
|
||||
|
||||
<div class="machine" id="machine"></div>
|
||||
|
|
@ -174,21 +187,50 @@ function colorFor(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:""};
|
||||
const state = {snap:null, open:new Set(), details:{}, sort:"cpu", hideIdle:true, q:"",
|
||||
rows:new Map(), dets:new Map(), segs:new Map(), leg:new Map(),
|
||||
rank:new Map(), low:new Map(), lastT:0, lastTabs:"", lastBanner:""};
|
||||
const ease = {busy:0, busyT:0, cores:0, coresT:0};
|
||||
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const elBusy = $("#busy .v"), elCores = $("#cores .v");
|
||||
|
||||
/* write only when the value actually changed */
|
||||
function setText(el,txt){ if(el && el.__t!==txt){ el.textContent=txt; el.__t=txt; } }
|
||||
function setHTML(el,html){ if(el && el.__h!==html){ el.innerHTML=html; el.__h=html; } }
|
||||
function setW(el,pct){ const v=pct.toFixed(2)+"%"; if(el && el.__w!==v){ el.style.width=v; el.__w=v; } }
|
||||
function keyed(parent,map,key,tag,cls){
|
||||
let el=map.get(key);
|
||||
if(!el){ el=document.createElement(tag); if(cls) el.className=cls; parent.appendChild(el); map.set(key,el); }
|
||||
return el;
|
||||
}
|
||||
function prune(map,keep){ for(const [k,el] of map){ if(!keep.has(k)){ el.remove(); map.delete(k); } } }
|
||||
|
||||
/* one rAF loop eases the hero numbers toward the newest sample instead of snapping */
|
||||
function loop(){
|
||||
const step=(cur,tgt)=> {
|
||||
const d=tgt-cur;
|
||||
if(REDUCED || Math.abs(d)<0.02) return tgt;
|
||||
return cur + d*0.16;
|
||||
};
|
||||
ease.busy = step(ease.busy, ease.busyT);
|
||||
ease.cores = step(ease.cores, ease.coresT);
|
||||
setText(elBusy, String(Math.round(ease.busy)));
|
||||
setText(elCores, ease.cores.toFixed(2));
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
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){
|
||||
function sparkPath(series){
|
||||
const w=92,h=22;
|
||||
if(!series||series.length<2) return `<svg class="spark" viewBox="0 0 ${w} ${h}"></svg>`;
|
||||
if(!series||series.length<2) return "";
|
||||
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>`;
|
||||
return series.map((v,i)=>(i?"L":"M")+(i/(series.length-1)*w).toFixed(1)+" "+
|
||||
(h-2-(v/max)*(h-4)).toFixed(1)).join(" ");
|
||||
}
|
||||
function flagChips(w){
|
||||
return (w.flags||[]).map(f=>`<span class="chip">${f}</span>`).join("");
|
||||
|
|
@ -197,37 +239,50 @@ function flagChips(w){
|
|||
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>`;
|
||||
setText($("#meta"), `live · ${new Date(s.t*1000).toLocaleTimeString()} · ${s.sample_ms}ms sample`);
|
||||
if(s.host) setText($("#hostlabel"), "workload map · " + s.host);
|
||||
ease.busyT = s.total_pct; ease.coresT = s.total_busy;
|
||||
setText($("#coresmax"), " / " + cores);
|
||||
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`;
|
||||
setText($("#topname"), top? top.name : "—");
|
||||
setText($("#topsub"), top ? `largest workload · ${top.cpu.toFixed(2)} cores` : "largest workload");
|
||||
setText($("#selfcost"), `${s.self_cost.toFixed(2)} cores`);
|
||||
|
||||
// machine bar: top 8 workloads + rest, then free
|
||||
// machine bar: top 8 workloads + rest. Segments are keyed and reordered by
|
||||
// flex order, so widths actually animate instead of being recreated each tick.
|
||||
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>`;
|
||||
const segs=[];
|
||||
top8.forEach((w,i)=>segs.push({key:w.key,name:w.name,cpu:w.cpu,color:colorFor(w.key)}));
|
||||
if(rest>0.02) segs.push({key:"__rest",name:"rest",cpu:rest,color:"#3a434c"});
|
||||
const keepSeg=new Set();
|
||||
segs.forEach((sg,i)=>{
|
||||
keepSeg.add(sg.key);
|
||||
const el=keyed($("#machine"),state.segs,sg.key,"div");
|
||||
setW(el, Math.min(100, sg.cpu/cores*100));
|
||||
if(el.__bg!==sg.color){ el.style.background=sg.color; el.__bg=sg.color; }
|
||||
el.style.order=i;
|
||||
const lg=keyed($("#legend"),state.leg,sg.key,"span");
|
||||
lg.style.order=i;
|
||||
const sig=sg.name+"|"+sg.cpu.toFixed(2)+"|"+sg.color;
|
||||
if(lg.__s!==sig){
|
||||
lg.innerHTML=`<i style="background:${sg.color}"></i>${esc(sg.name)} <em>${sg.cpu.toFixed(2)}</em>`;
|
||||
lg.__s=sig;
|
||||
}
|
||||
$("#machine").innerHTML=bar; $("#legend").innerHTML=leg;
|
||||
});
|
||||
prune(state.segs,keepSeg); prune(state.leg,keepSeg);
|
||||
|
||||
$("#flags").innerHTML = (s.flags||[]).map(f=>{
|
||||
setHTML($("#flags"), (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("");
|
||||
}).join(""));
|
||||
$("#flags").style.display = (s.flags||[]).length ? "flex" : "none";
|
||||
|
||||
// browser tabs
|
||||
// browser tabs: rebuilt only when the tab data itself changes
|
||||
const tabSig=JSON.stringify(s.tabs||[]);
|
||||
if(state.lastTabs!==tabSig){
|
||||
state.lastTabs=tabSig;
|
||||
const tabRows = (s.tabs||[]).map(t=>{
|
||||
const meta = [shortUrl(t.url)];
|
||||
if(t.port) meta.push("port "+t.port);
|
||||
|
|
@ -240,53 +295,130 @@ function render(){
|
|||
<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">
|
||||
const banner = (!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>` : "";
|
||||
if(state.lastBanner!==banner){ state.lastBanner=banner; $("#banner").innerHTML=banner; }
|
||||
|
||||
// rows
|
||||
// rows: keyed DOM, hysteresis on appearance, quantised sort so rows do not
|
||||
// swap places over a hundredth of a core
|
||||
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));
|
||||
const q=state.q.toLowerCase();
|
||||
if(q) list=list.filter(w=>(w.name+" "+w.key).toLowerCase().includes(q));
|
||||
|
||||
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>`;
|
||||
const visible=[];
|
||||
for(const w of list){
|
||||
if(!state.hideIdle){ state.low.delete(w.key); visible.push(w); continue; }
|
||||
if(w.cpu>=0.05){ state.low.delete(w.key); visible.push(w); continue; }
|
||||
// already on screen and still above 0.02: keep it for two more samples
|
||||
const n=(state.low.get(w.key)||0);
|
||||
if(state.rows.has(w.key) && w.cpu>=0.02 && n<2){ state.low.set(w.key,n+1); visible.push(w); }
|
||||
}
|
||||
|
||||
const by=state.sort;
|
||||
const qz=x=>Math.round(x*10)/10;
|
||||
const prevRank=state.rank;
|
||||
visible.sort((a,b)=>{
|
||||
let d;
|
||||
if(by==="cpu") d=qz(b.cpu)-qz(a.cpu);
|
||||
else if(by==="ram") d=qz(b.rss_mb/100)-qz(a.rss_mb/100);
|
||||
else d=qz(b.age_h*10)-qz(a.age_h*10);
|
||||
if(Math.abs(d)>0.0001) return d;
|
||||
return (prevRank.get(a.key)??1e9)-(prevRank.get(b.key)??1e9);
|
||||
});
|
||||
state.rank=new Map(visible.map((w,i)=>[w.key,i]));
|
||||
|
||||
const maxCpu=Math.max(0.2,...visible.map(w=>w.cpu));
|
||||
const host=$("#rows");
|
||||
const keepRows=new Set();
|
||||
|
||||
visible.forEach((w,i)=>{
|
||||
keepRows.add(w.key);
|
||||
let el=state.rows.get(w.key);
|
||||
if(!el){ el=buildRow(w.key); host.appendChild(el); state.rows.set(w.key,el); }
|
||||
el.style.order = i*2;
|
||||
updateRow(el,w,maxCpu);
|
||||
if(state.open.has(w.key)){
|
||||
let det=state.dets.get(w.key);
|
||||
if(!det){ det=document.createElement("div"); det.className="detail";
|
||||
det.dataset.detail=w.key; host.appendChild(det); state.dets.set(w.key,det); }
|
||||
det.style.order = i*2+1;
|
||||
updateDetail(det,w,state.details[w.key]);
|
||||
}
|
||||
});
|
||||
$("#rows").innerHTML = html || `<div class="empty">nothing matches</div>`;
|
||||
|
||||
document.querySelectorAll(".row").forEach(el=>{
|
||||
for(const [k,el] of state.rows){
|
||||
if(!keepRows.has(k)){ el.remove(); state.rows.delete(k); state.rank.delete(k); }
|
||||
}
|
||||
for(const [k,el] of state.dets){
|
||||
if(!keepRows.has(k) || !state.open.has(k)){ el.remove(); state.dets.delete(k); }
|
||||
}
|
||||
let empty=host.querySelector(".empty");
|
||||
if(!visible.length){
|
||||
if(!empty){ empty=document.createElement("div"); empty.className="empty"; host.appendChild(empty); }
|
||||
empty.textContent = q ? "nothing matches" : "nothing above the idle line";
|
||||
} else if(empty){ empty.remove(); }
|
||||
}
|
||||
|
||||
function buildRow(key){
|
||||
const el=document.createElement("div");
|
||||
el.className="row"; el.dataset.key=key;
|
||||
el.innerHTML=`<div class="nm"><span class="dot"></span>
|
||||
<div class="txt"><div class="t1"></div><div class="t2"></div></div></div>
|
||||
<div class="bar"><i></i></div>
|
||||
<div class="num pct"></div>
|
||||
<div class="num mono ccores"></div>
|
||||
<div class="num dim ram"></div>
|
||||
<div class="num dim age"></div>
|
||||
<div class="sp"><svg class="spark" viewBox="0 0 92 22" preserveAspectRatio="none">
|
||||
<path fill="none" stroke="#666" stroke-width="1.4" stroke-linejoin="round"/></svg></div>
|
||||
<div class="chev">▼</div>`;
|
||||
el.onclick=()=>{
|
||||
const k=el.dataset.key;
|
||||
if(state.open.has(k)){ state.open.delete(k); }
|
||||
if(state.open.has(k)) state.open.delete(k);
|
||||
else { state.open.add(k); loadDetail(k); }
|
||||
render();
|
||||
};
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function updateRow(el,w,maxCpu){
|
||||
const c=colorFor(w.key);
|
||||
const dot=el.querySelector(".dot");
|
||||
if(dot.__c!==c){ dot.style.background=c; dot.__c=c; }
|
||||
const t1=el.querySelector(".t1");
|
||||
const sig=w.name+"|"+(w.flags||[]).join(",");
|
||||
if(t1.__s!==sig){ t1.innerHTML=esc(w.name)+flagChips(w); t1.__s=sig; }
|
||||
setText(el.querySelector(".t2"), meta2(w));
|
||||
const bar=el.querySelector(".bar i");
|
||||
if(bar.__c!==c){ bar.style.background=c; bar.__c=c; }
|
||||
setW(bar, Math.max(0,Math.min(100, w.cpu/maxCpu*100)));
|
||||
setText(el.querySelector(".pct"), w.pct.toFixed(1)+"%");
|
||||
setText(el.querySelector(".ccores"), w.cpu.toFixed(2));
|
||||
setText(el.querySelector(".ram"), fmtRam(w.rss_mb));
|
||||
setText(el.querySelector(".age"), fmtAge(w.age_h));
|
||||
const d=state.details[w.key];
|
||||
const path=el.querySelector(".sp path");
|
||||
const pd=sparkPath(w.series || (d && d.series));
|
||||
if(path.__d!==pd){ path.setAttribute("d", pd); path.__d=pd; }
|
||||
if(path.__c!==c){ path.setAttribute("stroke", c); path.__c=c; }
|
||||
const open=state.open.has(w.key);
|
||||
if(el.__open!==open){
|
||||
el.classList.toggle("open", open);
|
||||
el.querySelector(".chev").textContent = open ? "▲" : "▼";
|
||||
el.__open=open;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDetail(det,w,d){
|
||||
const sig=(d? JSON.stringify(d) : "pending")+w.name;
|
||||
if(det.__s===sig) return;
|
||||
det.__s=sig;
|
||||
det.innerHTML=detailHTML(w,d);
|
||||
}
|
||||
|
||||
function meta2(w){
|
||||
|
|
@ -363,12 +495,17 @@ async function loadDetail(key){
|
|||
}
|
||||
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);
|
||||
const r=await fetch("/api/snapshot",{cache:"no-store"});
|
||||
const s=await r.json();
|
||||
// The sampler produces a frame every 2s. Poll faster than that so a new
|
||||
// frame is picked up within ~0.7s, and ignore repeats so the DOM is never
|
||||
// rewritten with numbers the user already sees.
|
||||
if(s.t===state.lastT) return;
|
||||
state.lastT=s.t;
|
||||
state.snap=s;
|
||||
render();
|
||||
}catch(e){ $("#meta").textContent="server unreachable"; }
|
||||
for(const k of state.open) if(state.details[k]) loadDetail(k);
|
||||
}catch(e){ setText($("#meta"),"server unreachable"); }
|
||||
}
|
||||
$("#q").oninput=e=>{state.q=e.target.value;render();};
|
||||
$("#sortbtn").onclick=e=>{
|
||||
|
|
@ -381,7 +518,8 @@ $("#idlebtn").onclick=e=>{
|
|||
e.target.textContent = state.hideIdle?"show idle":"hide idle";
|
||||
render();
|
||||
};
|
||||
tick(); setInterval(tick, 2000);
|
||||
tick();
|
||||
setInterval(tick, 700);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
15
install.sh
15
install.sh
|
|
@ -25,3 +25,18 @@ else
|
|||
echo "Vitals failed to start. Check: journalctl --user -u vitals -n 40"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- bookmark bar -------------------------------------------------------
|
||||
# A tool you have to remember the URL of does not get used. Put it behind the
|
||||
# bar, where a bookmark belongs. The browser must be closed while we write,
|
||||
# because Chromium keeps the bookmark model in memory and overwrites the file
|
||||
# on its next save. Running install.sh while the browser is up is not a
|
||||
# failure: it prints the one command to run afterwards.
|
||||
if [ "${1:-}" = "--no-bookmark" ]; then
|
||||
echo
|
||||
echo "Bookmark skipped. Add it later with:"
|
||||
echo " python3 $HERE/bookmark.py --install"
|
||||
else
|
||||
echo
|
||||
python3 "$HERE/bookmark.py" --install ${VITALS_BOOKMARK_WAIT:+--wait "$VITALS_BOOKMARK_WAIT"} || true
|
||||
fi
|
||||
|
|
|
|||
115
tests/test_bookmark.py
Normal file
115
tests/test_bookmark.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Unit tests for the bookmark installer. No browser, no real profile needed."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import bookmark # noqa: E402
|
||||
|
||||
|
||||
def minimal_doc():
|
||||
"""The smallest document Chromium would write: three roots, no children."""
|
||||
return {
|
||||
"checksum": "",
|
||||
"roots": {
|
||||
"bookmark_bar": {"children": [], "date_added": "0", "date_last_used": "0",
|
||||
"date_modified": "0", "guid": "0" * 32, "id": "1",
|
||||
"name": "Bookmarks bar", "type": "folder"},
|
||||
"other": {"children": [], "date_added": "0", "date_last_used": "0",
|
||||
"date_modified": "0", "guid": "1" * 32, "id": "2",
|
||||
"name": "Other bookmarks", "type": "folder"},
|
||||
"synced": {"children": [], "date_added": "0", "date_last_used": "0",
|
||||
"date_modified": "0", "guid": "2" * 32, "id": "3",
|
||||
"name": "Mobile bookmarks", "type": "folder"},
|
||||
},
|
||||
"sync_metadata": "",
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
|
||||
class TestChecksum(unittest.TestCase):
|
||||
def test_checksum_is_stable_and_order_sensitive(self):
|
||||
doc = minimal_doc()
|
||||
base = bookmark.checksum(doc)
|
||||
self.assertEqual(base, bookmark.checksum(minimal_doc()))
|
||||
doc["roots"]["bookmark_bar"]["children"].append(
|
||||
{"id": "4", "name": "x", "type": "url", "url": "http://a/"})
|
||||
self.assertNotEqual(base, bookmark.checksum(doc))
|
||||
|
||||
def test_title_bytes_are_utf16(self):
|
||||
# A folder named "A" and a folder named "\u0041" are the same string;
|
||||
# but a two byte title must not hash like its UTF-8 encoding.
|
||||
a = minimal_doc()
|
||||
a["roots"]["other"]["name"] = "A"
|
||||
b = minimal_doc()
|
||||
b["roots"]["other"]["name"] = "A\u0000"
|
||||
self.assertNotEqual(bookmark.checksum(a), bookmark.checksum(b))
|
||||
|
||||
|
||||
class TestInstall(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="vitals-bm-")
|
||||
self.profile = os.path.join(self.dir, "Default")
|
||||
os.makedirs(self.profile)
|
||||
doc = minimal_doc()
|
||||
doc["checksum"] = bookmark.checksum(doc)
|
||||
with open(os.path.join(self.profile, "Bookmarks"), "w", encoding="utf8") as f:
|
||||
json.dump(doc, f, indent=3, sort_keys=True)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def read(self):
|
||||
with open(os.path.join(self.profile, "Bookmarks"), encoding="utf8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def test_install_adds_both_entries_and_valid_checksum(self):
|
||||
bookmark.install(self.profile)
|
||||
doc = self.read()
|
||||
self.assertEqual(doc["checksum"], bookmark.checksum(doc))
|
||||
urls = [c["url"] for c in doc["roots"]["bookmark_bar"]["children"]]
|
||||
self.assertIn(bookmark.VITALS_URL, urls)
|
||||
self.assertTrue(any(u.startswith("javascript:") for u in urls))
|
||||
|
||||
def test_install_is_idempotent(self):
|
||||
bookmark.install(self.profile)
|
||||
bookmark.install(self.profile)
|
||||
bar = self.read()["roots"]["bookmark_bar"]["children"]
|
||||
self.assertEqual(len(bar), 2)
|
||||
|
||||
def test_ids_do_not_collide_with_existing_nodes(self):
|
||||
doc = self.read()
|
||||
doc["roots"]["bookmark_bar"]["children"].append(
|
||||
{"id": "99", "name": "existing", "type": "url", "url": "http://b/"})
|
||||
doc["checksum"] = bookmark.checksum(doc)
|
||||
with open(os.path.join(self.profile, "Bookmarks"), "w", encoding="utf8") as f:
|
||||
json.dump(doc, f, indent=3, sort_keys=True)
|
||||
bookmark.install(self.profile)
|
||||
ids = [c["id"] for c in self.read()["roots"]["bookmark_bar"]["children"]]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
|
||||
def test_remove_takes_only_its_own_entries(self):
|
||||
doc = self.read()
|
||||
doc["roots"]["bookmark_bar"]["children"].append(
|
||||
{"id": "50", "name": "keep me", "type": "url", "url": "http://keep/"})
|
||||
doc["checksum"] = bookmark.checksum(doc)
|
||||
with open(os.path.join(self.profile, "Bookmarks"), "w", encoding="utf8") as f:
|
||||
json.dump(doc, f, indent=3, sort_keys=True)
|
||||
bookmark.install(self.profile)
|
||||
bookmark.remove(self.profile)
|
||||
bar = self.read()["roots"]["bookmark_bar"]["children"]
|
||||
self.assertEqual([c["name"] for c in bar], ["keep me"])
|
||||
|
||||
def test_backup_is_written_before_touching_the_file(self):
|
||||
bookmark.install(self.profile)
|
||||
backups = [f for f in os.listdir(self.profile) if f.startswith("Bookmarks.pre-vitals-")]
|
||||
self.assertEqual(len(backups), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue