Commit 2960c9fb by PLN (Algolia)

feat(perf): smart-silent mode + Bridge/tray thermal cockpit

Problem: the laptop ran hot even idle, and the bafd3133 RAPL watt caps were
committed but NEVER deployed — the live /usr/local/sbin/perf-audio predated
them, so every --cool set clock-% while RAPL sat at the BIOS 135W (measured
90C under an all-core encode). Watts are the thermal lever, not percent.

- perf.sh: new --silent ("smart-silent") — powersave + EPP power + 55% clock
  ceiling + a hard 12W/25W RAPL cap + allow_dgpu_suspend (nudge the discrete
  GPU toward D3cold) + audio still RT. Quieter than --cool without disabling
  turbo (low PL2 keeps SuperDirt DSP transients from xrunning). Tunable via
  SILENT_PL1_W / SILENT_MAX_PCT.
- perf-audio.sudoers: whitelist --silent (flag-scoped, no wildcard).
- tools/bridge/perf.py: MODES gains silent (tray + web inherit it);
  detect_mode disambiguates silent vs cool by EPP; snapshot() gains cores_pct
  (per-cpu util deltas), dgpu (runtime_status), thermald, watts (live RAPL
  caps), power_w (actual draw — null until energy_uj is unlocked, then lights).
- ui/index.html: the bar is a cockpit now — 16 "dancing cores" (height=util,
  colour=temp, hover=cpu/util/temp), folded chips (watt cap / dGPU asleep-awake
  / thermald), a distinct teal+snowflake tint for Silent, and a 60s temp+power
  sparkline.
- perf-tray.py: the icon's decorative sine is now a REAL temp-history
  sparkline; tooltip + menu fold in cap/gpu/thermald.

Verified: POST /api/perf {silent} -> live RAPL 12/25W; {cool} -> 28/50W; the
135W furnace is leashed. perf tests 7-pass.
parent 3a973eec
# perf-tray: let pln switch perf.sh modes without a password prompt, scoped to
# the ROOT-OWNED deployed copy and the exact mode flags only (no wildcard).
#
# Why a deployed copy and not ~/Work/Sound/Tidal/perf.sh directly:
# NOPASSWD on a user-writable script = passwordless root by the back door
# (you could edit the script to do anything). The deployed copy is owned by
# root and only root can change it, so the privilege stays scoped to the
# vetted modes below.
#
# Install:
# sudo install -m 755 -o root -g root ~/Work/Sound/Tidal/perf.sh /usr/local/sbin/perf-audio
# sudo install -m 440 -o root -g root ~/Work/Sound/Tidal/perf-audio.sudoers /etc/sudoers.d/perf-audio
# sudo visudo -cf /etc/sudoers.d/perf-audio # validate
#
# Re-run the first line whenever you change perf.sh and want the tray to pick it up.
pln ALL=(root) NOPASSWD: /usr/local/sbin/perf-audio --cool, /usr/local/sbin/perf-audio --silent, /usr/local/sbin/perf-audio --optimize, /usr/local/sbin/perf-audio --extreme, /usr/local/sbin/perf-audio --stop
......@@ -33,7 +33,8 @@ from PyQt5.QtCore import QTimer, Qt, QProcess, QRect
# Shared perf logic (DRY with the web Bridge). The tray lives at the repo root;
# perf.py lives under tools/bridge.
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools" / "bridge"))
from perf import Thermals, detect_mode, MODES, SCRIPT, write_desired # noqa: E402
from perf import (Thermals, detect_mode, MODES, SCRIPT, write_desired, # noqa: E402
dgpu_state, thermald_active, power_caps_w)
import launchers as LA # noqa: E402
SERVICE = "perf-tray" # systemd --user unit controlling autostart
......@@ -76,8 +77,9 @@ def temp_color(c):
return QColor("#c62828") # red
def make_icon(temp_c):
"""Heat-coloured badge with the ParVagues wave signature + the temp number."""
def make_icon(temp_c, hist=None):
"""Heat-coloured badge + the temp number over a REAL sparkline of the recent
package-temp history (the wave is live data now, not a decorative sine)."""
px = QPixmap(64, 64)
px.fill(Qt.transparent)
p = QPainter(px)
......@@ -88,25 +90,35 @@ def make_icon(temp_c):
p.setPen(Qt.NoPen)
p.drawRoundedRect(2, 2, 60, 60, 14, 14)
# ParVagues signature wave across the lower third (two cycles, translucent)
# live temp-history sparkline across the lower band (auto-scaled to its
# own min/max + a little pad, so small wiggles are still legible)
pts = [h for h in (hist or []) if h is not None]
if len(pts) >= 2:
lo, hi = min(pts), max(pts)
if hi - lo < 6:
mid = (hi + lo) / 2
lo, hi = mid - 3, mid + 3
y_bot, y_top = 59, 40
path = QPainterPath()
mid, amp = 47, 6
path.moveTo(2, mid)
for x in range(2, 63):
path.lineTo(x, mid - amp * math.sin((x - 2) / 60 * 2 * math.pi * 2))
pen = QPen(QColor(255, 255, 255, 165))
pen.setWidth(3)
n = len(pts)
for i, v in enumerate(pts):
x = 5 + (i / (n - 1)) * 54
y = y_bot - (v - lo) / (hi - lo) * (y_bot - y_top)
(path.moveTo if i == 0 else path.lineTo)(x, y)
pen = QPen(QColor(255, 255, 255, 180))
pen.setWidth(2)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
p.setPen(pen)
p.drawPath(path)
# temp number in the upper area, clear of the wave
# temp number in the upper area, clear of the sparkline
p.setPen(QColor("white"))
f = QFont()
f.setBold(True)
f.setPixelSize(30 if (temp_c is not None and temp_c < 100) else 24)
p.setFont(f)
p.drawText(QRect(0, 0, 64, 44), Qt.AlignCenter, "--" if temp_c is None else str(temp_c))
p.drawText(QRect(0, 0, 64, 38), Qt.AlignCenter, "--" if temp_c is None else str(temp_c))
p.end()
return QIcon(px)
......@@ -115,7 +127,8 @@ class PerfTray:
def __init__(self, app):
self.app = app
self.therm = Thermals()
self.tray = QSystemTrayIcon(make_icon(self.therm.package_c()))
self.hist = [] # rolling package-temp history for the icon sparkline
self.tray = QSystemTrayIcon(make_icon(self.therm.package_c(), self.hist))
self.tray.setToolTip("perf-tray")
self.menu = QMenu()
......@@ -226,7 +239,10 @@ class PerfTray:
def refresh(self):
t = self.therm
pkg = t.package_c()
self.tray.setIcon(make_icon(pkg))
self.hist.append(pkg)
if len(self.hist) > 24: # ~48s of history at the 2s refresh
self.hist.pop(0)
self.tray.setIcon(make_icon(pkg, self.hist))
mode = detect_mode()
if mode in self.mode_actions:
......@@ -241,21 +257,26 @@ class PerfTray:
fans = t.fans_rpm()
cores = t.cores_c()
hottest = max(cores) if cores else pkg
w = power_caps_w()
g = dgpu_state()
cap = f"{w['pl1']}/{w['pl2']}W" if w.get("pl1") is not None else "–"
gpu = "none" if not g.get("present") else ("asleep" if g.get("status") == "suspended" else "awake")
td = "on" if thermald_active() else "off"
label = MODES.get(mode, ("?",))[0]
tip = (
f"perf-tray — mode: {MODES.get(mode, ('?',))[0]}\n"
f"perf-tray — mode: {label} · cap {cap}\n"
f"package {pkg}°C (hottest core {hottest}°C)\n"
f"freq {favg}/{fmax} MHz avg/max\n"
f"fans {' / '.join(str(f) for f in fans) or 'n/a'} RPM\n"
f"throttle Δ {t.throttle_delta()} since launch"
f"freq {favg}/{fmax} MHz avg/max · fans {' / '.join(str(f) for f in fans) or 'n/a'} RPM\n"
f"gpu {gpu} · thermald {td} · throttle Δ {t.throttle_delta()}"
)
self.tray.setToolTip(tip)
rows = [
f"mode: {MODES.get(mode, ('?',))[0]}",
f"mode: {label} · cap {cap}",
f"package {pkg}°C · hottest core {hottest}°C",
f"freq {favg}/{fmax} MHz · fans {'/'.join(str(f) for f in fans) or 'n/a'} RPM",
f"throttle Δ {t.throttle_delta()} since launch",
f"gpu {gpu} · thermald {td} · throttle Δ {t.throttle_delta()}",
]
for a, txt in zip(self.stat_actions, rows):
a.setText(txt)
......
......@@ -17,6 +17,12 @@ COOL_MAX_PCT="${COOL_MAX_PCT:-85}"
# Watts bound heat directly; percent clock caps don't on multi-core load.
COOL_PL1_W="${COOL_PL1_W:-28}"
COOL_PL2_W="${COOL_PL2_W:-50}"
# Smart-silent caps: quieter than --cool. A hard sustained-watt floor keeps the
# fans near-idle; turbo stays ON but the low PL2 bounds bursts so SuperDirt's
# DSP transients still clear without an xrun. 12W ≈ fans barely spin on this chip.
SILENT_MAX_PCT="${SILENT_MAX_PCT:-55}"
SILENT_PL1_W="${SILENT_PL1_W:-12}"
SILENT_PL2_W="${SILENT_PL2_W:-25}"
SAVED_PL1="/tmp/saved_rapl_pl1.txt"
SAVED_PL2="/tmp/saved_rapl_pl2.txt"
RAPL_DIR="/sys/class/powercap/intel-rapl:0"
......@@ -32,6 +38,8 @@ show_help() {
echo " --extreme Apply extreme performance optimizations (for live performance)"
echo " --cool Thermal-aware mode: fix audio RT priorities + cap peak clock"
echo " (heatwave / fanless-feel; keeps audio snappy without cooking the CPU)"
echo " --silent Smart-silent: as quiet as possible — hard ${SILENT_PL1_W:-12}W watt cap +"
echo " lower clock ceiling + dGPU suspend; audio stays real-time"
echo " --stop Reset system to normal operation"
echo " --check Check current system load and running processes"
echo " --diagnose Run diagnostics to investigate audio stutters"
......@@ -433,6 +441,94 @@ optimize_cool() {
echo " Reset anytime: sudo $0 --stop"
}
# Nudge the discrete GPU to power down (D3cold) when idle: set its runtime PM to
# auto. Only touches non-Intel GPUs (leaves the iGPU alone). If a client still
# holds the dGPU it stays awake — we report that so it's not a silent mystery.
# Free watts when nothing needs it; no-op when something does.
allow_dgpu_suspend() {
local found=0
for d in /sys/bus/pci/devices/*/; do
local cls ven st
cls=$(cat "$d/class" 2>/dev/null)
case "$cls" in 0x0300*|0x0302*) ;; *) continue ;; esac # VGA / 3D controller
ven=$(cat "$d/vendor" 2>/dev/null)
[ "$ven" = "0x8086" ] && continue # skip Intel iGPU
found=1
echo auto > "$d/power/control" 2>/dev/null
st=$(cat "$d/power/runtime_status" 2>/dev/null)
echo "✓ dGPU $(basename "$d") runtime PM → auto (status: ${st:-unknown})"
[ "$st" = "active" ] && echo " ↳ still held by a client (see: nvidia-smi) — will suspend once released"
done
[ "$found" = 0 ] && echo " (no discrete GPU present)"
}
# Function to apply SMART-SILENT optimizations ("as quiet as possible")
# Quieter than --cool: a hard sustained-watt cap + a lower peak-clock ceiling so
# the fans stay near-idle, plus a nudge to let the discrete GPU power down. Audio
# still gets real RT priority (glitch-free at the low clock), and the low PL2
# leaves brief turbo headroom for DSP transients. "Smart" = it reports where it
# lands thermally and leans on the Bridge watcher to hold the regime.
optimize_silent() {
check_root "--silent"
echo "========================================"
echo "APPLYING SMART-SILENT OPTIMIZATIONS"
echo "========================================"
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor > $SAVED_CPU_GOVERNOR
cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference > $SAVED_EPP 2>/dev/null
cat /sys/devices/system/cpu/intel_pstate/max_perf_pct > $SAVED_MAX_PCT 2>/dev/null
echo "Quieting background CPU wakers..."
USER=$(logname || whoami)
systemctl --user -M $USER@ stop kde-baloo.service 2>/dev/null
systemctl --user -M $USER@ stop plasma-baloorunner.service 2>/dev/null
systemctl stop packagekit.service 2>/dev/null
echo "✓ Background services quieted"
echo powersave | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor > /dev/null
echo "✓ Governor: powersave"
# Bias hard toward efficiency: quietest idle, gentlest ramp.
if [ -f /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference ]; then
echo power | tee /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference > /dev/null 2>&1
echo "✓ EPP: power (max efficiency)"
fi
if [ -d /sys/devices/system/cpu/intel_pstate ]; then
echo "$SILENT_MAX_PCT" > /sys/devices/system/cpu/intel_pstate/max_perf_pct 2>/dev/null
echo 10 > /sys/devices/system/cpu/intel_pstate/min_perf_pct 2>/dev/null
echo 0 > /sys/devices/system/cpu/intel_pstate/no_turbo 2>/dev/null
echo "✓ Peak clock capped to ${SILENT_MAX_PCT}% (turbo bounded by the watt cap below)"
fi
# The real quiet lever: a hard sustained-power cap. PL1 low = fans near-idle;
# PL2 leaves brief burst headroom for audio DSP so it doesn't xrun.
set_power_caps "$SILENT_PL1_W" "$SILENT_PL2_W"
echo 10 > /proc/sys/vm/swappiness 2>/dev/null
echo "✓ Swappiness lowered to 10"
# Let the discrete GPU sleep if nothing needs it.
allow_dgpu_suspend
# Keep the audio chain real-time so the low clock stays glitch-free.
set_priorities silent
# Smart bit: report where we landed so the mode explains itself.
for z in /sys/class/thermal/thermal_zone*; do
[ "$(cat "$z/type" 2>/dev/null)" = x86_pkg_temp ] && pkg=$(cat "$z/temp" 2>/dev/null) && break
done
[ -n "${pkg:-}" ] && echo " Package now $((pkg/1000))°C — will settle under the ${SILENT_PL1_W}W cap."
echo ""
echo "✅ Smart-silent applied — as quiet as possible under a ${SILENT_PL1_W}W sustained cap."
echo " • Audio still real-time scheduled (glitch-free at the lower clock)."
echo " • Discrete GPU nudged to suspend; ${SILENT_MAX_PCT}% clock ceiling."
echo " • If a heavy patch xruns, step up one notch: sudo $0 --cool"
echo " Tune: SILENT_PL1_W=NN (watts, lower=quieter) / SILENT_MAX_PCT=NN. Reset: sudo $0 --stop"
}
# Function to set process priorities
set_priorities() {
mode=$1
......@@ -786,6 +882,9 @@ case "$1" in
--cool)
optimize_cool
;;
--silent)
optimize_silent
;;
--stop)
reset_system
;;
......
......@@ -20,8 +20,11 @@ import time
# Root-owned, sudoers-whitelisted deployment of perf.sh (same as perf-tray).
SCRIPT = os.environ.get("PERF_TRAY_SCRIPT", "/usr/local/sbin/perf-audio")
# mode key -> (label, perf.sh flag)
# mode key -> (label, perf.sh flag). Order = coolest → hottest → normal; the
# tray menu and Bridge toolbar both iterate this, so a new mode lights up in
# both faces automatically.
MODES = {
"silent": ("Silent", "--silent"),
"cool": ("Cool", "--cool"),
"standard": ("Standard", "--optimize"),
"extreme": ("Extreme", "--extreme"),
......@@ -55,6 +58,60 @@ def _hwmon_by_name(name):
return None
def _cpu_jiffies():
"""Per-logical-cpu (busy_total, idle_total) jiffies from /proc/stat."""
out = {}
try:
with open("/proc/stat") as f:
for line in f:
if not (line.startswith("cpu") and len(line) > 3 and line[3].isdigit()):
continue
parts = line.split()
vals = list(map(int, parts[1:]))
idle = vals[3] + (vals[4] if len(vals) > 4 else 0) # idle + iowait
out[int(parts[0][3:])] = (sum(vals), idle)
except (OSError, ValueError, IndexError):
pass
return out
def dgpu_state():
"""Discrete (non-Intel) GPU power state, or {'present': False}."""
for d in glob.glob("/sys/bus/pci/devices/*"):
cls = _read(os.path.join(d, "class")) or ""
if not (cls.startswith("0x0300") or cls.startswith("0x0302")):
continue
if (_read(os.path.join(d, "vendor")) or "") == "0x8086":
continue # skip the Intel iGPU
return {
"present": True,
"status": _read(os.path.join(d, "power/runtime_status")), # active|suspended
"control": _read(os.path.join(d, "power/control")), # auto|on
}
return {"present": False}
def thermald_active():
"""True if the thermald process is running (cheap /proc scan, no fork)."""
for c in glob.glob("/proc/[0-9]*/comm"):
if (_read(c) or "") == "thermald":
return True
return False
_RAPL = "/sys/class/powercap/intel-rapl:0"
def power_caps_w():
"""Current RAPL package caps in watts (PL1 sustained / PL2 burst)."""
def w(c):
v = _read_int(f"{_RAPL}/constraint_{c}_power_limit_uw")
return round(v / 1_000_000) if v else None
return {"pl1": w(0), "pl2": w(1)}
def temp_state(c):
if c is None:
return "unknown"
......@@ -72,6 +129,10 @@ class Thermals:
self.dell = _hwmon_by_name("dell_smm")
self._pkg_input = self._find_label_input(self.coretemp, "Package id 0")
self._throttle_base = self._throttle_sum()
self._prev_jiffies = _cpu_jiffies() # seed for per-core util deltas
self._e_prev = _read_int(f"{_RAPL}/energy_uj") # None if root-only (usual)
self._e_t = time.monotonic()
self._e_max = _read_int(f"{_RAPL}/max_energy_range_uj")
def _find_label_input(self, chip, label):
if not chip:
......@@ -123,6 +184,38 @@ class Thermals:
def throttle_delta(self):
return self._throttle_sum() - self._throttle_base
def power_draw_w(self):
"""Actual package power draw (W) from the RAPL energy counter, or None
if energy_uj isn't readable (root-only on most kernels — a udev rule can
open it, and this then lights up automatically)."""
cur = _read_int(f"{_RAPL}/energy_uj")
now = time.monotonic()
prev, pt = self._e_prev, self._e_t
self._e_prev, self._e_t = cur, now
if cur is None or prev is None:
return None
dt, de = now - pt, cur - prev
if de < 0 and self._e_max: # counter wrapped
de += self._e_max
if dt <= 0 or de < 0:
return None
return round(de / dt / 1_000_000, 1) # µJ/s → W
def cpu_pct(self):
"""Per-logical-cpu utilization 0-100 since the previous call (the
'dancing cores'). First call after init returns [] until there's a
delta to diff against."""
cur = _cpu_jiffies()
prev, self._prev_jiffies = self._prev_jiffies, cur
out = []
for n in sorted(cur):
if n not in prev:
continue
dt = cur[n][0] - prev[n][0]
di = cur[n][1] - prev[n][1]
out.append(max(0, min(100, round(100 * (dt - di) / dt))) if dt > 0 else 0)
return out
def detect_mode():
gov = _read("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
......@@ -133,6 +226,10 @@ def detect_mode():
return "extreme"
if gov == "performance":
return "standard"
# Silent and cool are both powersave + capped clock; the EPP tells them apart
# (silent biases all the way to "power", cool keeps "balance_performance").
if gov == "powersave" and epp in ("power", "balance_power") and (maxp or 100) < 100:
return "silent"
if epp == "balance_performance" and (maxp or 100) < 100:
return "cool"
return "normal"
......@@ -264,10 +361,15 @@ def snapshot(therm: "Thermals | None" = None) -> dict:
"package_c": pkg,
"temp_state": temp_state(pkg),
"cores_c": t.cores_c(),
"cores_pct": t.cpu_pct(),
"fans_rpm": t.fans_rpm(),
"freq_max_mhz": fmax,
"freq_avg_mhz": favg,
"throttle_delta": t.throttle_delta(),
"power_w": t.power_draw_w(),
"watts": power_caps_w(),
"dgpu": dgpu_state(),
"thermald": thermald_active(),
"script_available": script_available(),
"modes": {k: v[0] for k, v in MODES.items()},
}
......@@ -19,9 +19,24 @@ def test_temp_state_thresholds():
def test_modes_shape():
assert set(P.MODES) == {"cool", "standard", "extreme", "normal"}
assert set(P.MODES) == {"silent", "cool", "standard", "extreme", "normal"}
assert P.MODES["extreme"][1] == "--extreme"
assert P.MODES["normal"][1] == "--stop"
assert P.MODES["silent"][1] == "--silent"
def test_detect_mode_silent_vs_cool(monkeypatch):
# silent and cool share powersave + a capped clock; EPP disambiguates.
def fake_reads(gov, epp, maxp):
monkeypatch.setattr(P, "_read", lambda p, d=None:
{"scaling_governor": gov,
"energy_performance_preference": epp}.get(p.split("/")[-1], d))
monkeypatch.setattr(P, "_read_int", lambda p, d=None:
maxp if p.endswith("max_perf_pct") else (0 if p.endswith("min_perf_pct") else d))
fake_reads("powersave", "power", 55)
assert P.detect_mode() == "silent"
fake_reads("powersave", "balance_performance", 85)
assert P.detect_mode() == "cool"
def test_set_mode_rejects_unknown():
......
......@@ -31,9 +31,32 @@
.modes button:last-child{border-right:0}
.modes button:hover{background:var(--overlay);color:var(--ink)}
.modes button.on{background:var(--brand-deep);color:#fff}
/* Silent = the "quietest" mode: calm teal + a frost mark, distinct from brand magenta */
.modes button[data-m="silent"]::before{content:"❄";margin-right:5px;opacity:.5;font-size:10px}
.modes button.on[data-m="silent"]{background:#0e7490}
.modes button.on[data-m="silent"]::before{opacity:1}
.modes button:disabled{opacity:.4;cursor:not-allowed}
.stat{font-family:var(--mono);font-size:11px;color:var(--faint)}
.stat b{color:var(--mute);font-weight:600}
/* dancing cores — height = load, colour = that core's temp; hover for detail */
.cores{display:flex;gap:2px;align-items:flex-end;height:26px;cursor:help}
.cores .c{width:5px;height:100%;background:#ffffff12;border-radius:2px;
position:relative;overflow:hidden}
.cores .c i{position:absolute;left:0;right:0;bottom:0;height:0;background:var(--cool);
transition:height .35s ease,background .35s ease}
/* folded-state chips (watt cap, dGPU, thermald) */
.chip{font-family:var(--mono);font-size:11px;color:var(--faint);
display:inline-flex;align-items:center;gap:5px;cursor:help}
.chip .k{color:var(--mute);font-weight:600}
.chip .d{width:8px;height:8px;border-radius:99px;background:var(--faint);flex:none}
.chip .d.ok{background:var(--cool);box-shadow:0 0 6px var(--cool)}
.chip .d.warn{background:var(--warm);box-shadow:0 0 6px var(--warm)}
.chip .d.off{background:var(--faint);box-shadow:none}
/* 60s sparkline — orange: package temp, cyan: power draw (or clock proxy) */
.spark{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;color:var(--mute);cursor:help}
.spark svg{display:block;background:#ffffff08;border-radius:4px}
#spTemp{fill:none;stroke:var(--hot);stroke-width:1.5;stroke-linejoin:round}
#spPow{fill:none;stroke:#22a5c0;stroke-width:1.5;stroke-linejoin:round;opacity:.85}
/* ── hub ─────────────────────────────────────────────────── */
main{max-width:880px;margin:0 auto;padding:28px 20px}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:0 0 14px;font-family:var(--mono)}
......@@ -82,6 +105,14 @@
<span class="stat" id="freq"><b>freq</b></span>
<span class="stat" id="fan"><b>fan</b></span>
<span class="stat" id="thr"><b>throttle</b> 0</span>
<div class="cores" id="cores" title="per-core load — bar height = utilisation, colour = temperature"></div>
<span class="chip" id="cap"><span class="k">cap</span></span>
<span class="chip" id="gpu"><span class="k">gpu</span><span class="d"></span></span>
<span class="chip" id="td"><span class="k">thermald</span><span class="d"></span></span>
<span class="spark" id="spark" title="last ~60s — orange: package temp · cyan: power draw (clock if watts are root-locked)">
<svg width="116" height="26" viewBox="0 0 116 26" preserveAspectRatio="none">
<polyline id="spPow" points=""></polyline><polyline id="spTemp" points=""></polyline>
</svg><span id="spVal"></span></span>
<div class="modes" id="modes"></div>
</div>
<div class="toast" id="toast"></div>
......@@ -102,6 +133,7 @@
<script>
const $=s=>document.querySelector(s), api=(u,o)=>fetch(u,o).then(r=>r.json());
let MODES={}, scriptOK=true;
const HIST=[], HMAX=30; // ~60s of history at the 2s poll interval
function toast(m,err=false){const t=$("#toast");t.textContent=m;t.className="toast"+(err?" err":"");}
function paint(s){
......@@ -112,10 +144,71 @@ function paint(s){
$("#freq").innerHTML=`<b>freq</b> ${s.freq_max_mhz?(s.freq_max_mhz/1000).toFixed(1)+"G":"–"}`;
$("#fan").innerHTML=`<b>fan</b> ${s.fans_rpm?.length?Math.max(...s.fans_rpm)+"rpm":"–"}`;
$("#thr").innerHTML=`<b>throttle</b> ${s.throttle_delta??0}`;
paintCores(s); paintState(s);
HIST.push({temp:s.package_c, pw:s.power_w, clk:s.freq_avg_mhz});
while(HIST.length>HMAX)HIST.shift();
drawSpark();
scriptOK=s.script_available;
if(JSON.stringify(MODES)!==JSON.stringify(s.modes)){MODES=s.modes;buildModes();}
document.querySelectorAll(".modes button").forEach(b=>b.classList.toggle("on",b.dataset.m===s.mode));
}
function spline(vals,lo,hi){
const W=116,H=26,pad=2,n=vals.length;
if(n<2)return"";
return vals.map((v,i)=>{
const x=(i/(n-1))*W;
const c=(v==null?lo:Math.max(lo,Math.min(hi,v)));
const y=H-pad-((c-lo)/(hi-lo))*(H-2*pad);
return x.toFixed(1)+","+y.toFixed(1);
}).join(" ");
}
function drawSpark(){
const temps=HIST.map(h=>h.temp);
const hasPw=HIST.some(h=>h.pw!=null);
const pow=HIST.map(h=>hasPw?h.pw:h.clk);
$("#spTemp").setAttribute("points",spline(temps,35,95));
if(hasPw){const mx=Math.max(45,...pow.filter(x=>x!=null));$("#spPow").setAttribute("points",spline(pow,0,mx));}
else{$("#spPow").setAttribute("points",spline(pow,800,5100));}
const l=HIST[HIST.length-1]||{};
$("#spVal").textContent=`${l.temp??"–"}° ${hasPw?((l.pw??"–")+"W"):((l.clk?(l.clk/1000).toFixed(1):"–")+"G")}`;
}
function heatColor(c){
if(c==null)return"var(--unknown)";
if(c<55)return"var(--cool)"; if(c<70)return"var(--warm)";
if(c<85)return"var(--hot)"; return"var(--critical)";
}
function paintCores(s){
const cp=s.cores_pct||[], ct=s.cores_c||[], box=$("#cores");
if(box.children.length!==cp.length){
box.innerHTML="";
for(let i=0;i<cp.length;i++){const c=document.createElement("div");
c.className="c";c.appendChild(document.createElement("i"));box.appendChild(c);}
}
[...box.children].forEach((c,i)=>{
const pct=cp[i]??0;
const temp=ct.length?ct[Math.min(ct.length-1,Math.floor(i*ct.length/Math.max(1,cp.length)))]:s.package_c;
c.firstChild.style.height=pct+"%";
c.firstChild.style.background=heatColor(temp);
c.title=`cpu ${i} · ${pct}% load${temp!=null?` · ${temp}°C`:""}`;
});
}
function paintState(s){
const w=s.watts||{}, cap=$("#cap");
cap.innerHTML=`<span class="k">cap</span> ${w.pl1!=null?`${w.pl1}/${w.pl2}W`:"–"}`;
cap.title=w.pl1!=null?`RAPL package power cap: ${w.pl1}W sustained · ${w.pl2}W burst`:"no RAPL cap readable";
const g=s.dgpu||{present:false}, gpu=$("#gpu");
if(g.present){
const asleep=g.status==="suspended";
gpu.innerHTML=`<span class="k">gpu</span><span class="d ${asleep?"ok":"warn"}"></span>${asleep?"asleep":"awake"}`;
gpu.title=`discrete GPU: ${g.status} · runtime PM ${g.control}`+(asleep?"":" — a client holds it awake (check nvidia-smi)");
}else{
gpu.innerHTML=`<span class="k">gpu</span><span class="d off"></span>none`;
gpu.title="no discrete GPU present";
}
const td=$("#td"), on=!!s.thermald;
td.innerHTML=`<span class="k">thermald</span><span class="d ${on?"ok":"off"}"></span>`;
td.title=on?"thermald active — adaptive thermal backstop":"thermald not running";
}
function buildModes(){
const box=$("#modes");box.innerHTML="";
Object.entries(MODES).forEach(([k,label])=>{
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment