Bugfix: Flickering overview charts because of bad choice of point

reducing method
This commit is contained in:
Johannes Schriewer 2026-08-30 19:09:02 +02:00
parent 6fdfde8dac
commit 9fa4e159af
6 changed files with 5210 additions and 50 deletions

View file

@ -77,9 +77,10 @@ agent's own shell command line and kills the session.
state. state.
- `iw dev <if> link` prints `SSID: name` **unquoted**; the working regex is - `iw dev <if> link` prints `SSID: name` **unquoted**; the working regex is
`SSID:\s+(\S.*)` (a `$` anchor fails without MULTILINE). `SSID:\s+(\S.*)` (a `$` anchor fails without MULTILINE).
- `/api/history` pads series with `null` for samples missing a key so all - `/api/history` window-averages the ring buffer down to at most
series stay aligned with the timestamps — keep that behaviour if you touch `chart_max_points` (default 200) points, emitting `{avg, min, max}` per key,
it. and pads each of the three arrays with `null` for windows missing a key so
they stay aligned with the timestamps — keep that behaviour if you touch it.
- AMD sysfs: GPU busy/VRAM/temp under - AMD sysfs: GPU busy/VRAM/temp under
`/sys/class/drm/card*/device` (+ `hwmon`), CPU temp from the `k10temp` `/sys/class/drm/card*/device` (+ `hwmon`), CPU temp from the `k10temp`
hwmon (fallback `acpitz` thermal zone), both in millidegrees. hwmon (fallback `acpitz` thermal zone), both in millidegrees.

View file

@ -10,6 +10,7 @@ class Settings(BaseSettings):
port: int = 8501 port: int = 8501
sample_interval: float = 2.0 sample_interval: float = 2.0
retention_minutes: int = 60 retention_minutes: int = 60
chart_max_points: int = 200
llama_base_url: str = "http://127.0.0.1:8080" llama_base_url: str = "http://127.0.0.1:8080"
llama_api_key: str = "" llama_api_key: str = ""

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import math
import socket import socket
import time import time
from typing import Any from typing import Any
@ -8,10 +9,39 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from app.collect import net as net_col from app.collect import net as net_col
from app.config import get_settings
from app.render import render, uptime_str from app.render import render, uptime_str
router = APIRouter(prefix="/api", tags=["overview"]) router = APIRouter(prefix="/api", tags=["overview"])
RowAgg = dict[str, float | int | None]
def _window(
snap: list[tuple[float, dict[str, float | int | None]]], max_points: int
) -> list[tuple[float, dict[str, RowAgg]]]:
n = len(snap)
w = max(1, math.ceil(n / max_points))
out: list[tuple[float, dict[str, RowAgg]]] = []
for start in range(0, n, w):
chunk = snap[start : start + w]
vals: dict[str, list[int | float]] = {}
for _, sample in chunk:
for k, v in sample.items():
if isinstance(v, (int, float)) and not isinstance(v, bool):
vals.setdefault(k, []).append(v)
row: dict[str, RowAgg] = {}
for k, lst in vals.items():
ints = all(isinstance(v, int) for v in lst)
avg = sum(lst) / len(lst)
row[k] = {
"avg": round(avg) if ints else round(avg, 1),
"min": min(lst) if ints else round(min(lst), 1),
"max": max(lst) if ints else round(max(lst), 1),
}
out.append((chunk[-1][0], row))
return out
@router.get("/overview") @router.get("/overview")
async def overview(request: Request): async def overview(request: Request):
@ -52,21 +82,17 @@ async def overview(request: Request):
@router.get("/history") @router.get("/history")
async def history(request: Request): async def history(request: Request):
snap = request.app.state.store.snapshot() snap = _window(request.app.state.store.snapshot(), get_settings().chart_max_points)
ts = [round(t, 1) for t, _ in snap] ts = [round(t, 1) for t, _ in snap]
keys: set[str] = set() keys: set[str] = set()
for _, sample in snap: for _, row in snap:
for k, v in sample.items(): keys.update(row)
if isinstance(v, (int, float)) and not isinstance(v, bool): series: dict[str, dict[str, list[Any]]] = {
keys.add(k) k: {"avg": [], "min": [], "max": []} for k in keys
series: dict[str, list[Any]] = {} }
for _, sample in snap: for _, row in snap:
for k in keys: for k in keys:
v = sample.get(k) agg = row.get(k)
if isinstance(v, (int, float)) and not isinstance(v, bool): for m in ("avg", "min", "max"):
if isinstance(v, float): series[k][m].append(agg[m] if agg else None)
v = round(v, 1)
else:
v = None
series.setdefault(k, []).append(v)
return JSONResponse({"ts": ts, "series": series}) return JSONResponse({"ts": ts, "series": series})

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View file

@ -36,15 +36,6 @@
return d.toLocaleTimeString([], { hour12: false }); return d.toLocaleTimeString([], { hour12: false });
} }
function downsampleIdx(len, max) {
if (len <= max) return null;
const step = Math.ceil(len / max);
const idx = [];
for (let i = 0; i < len; i += step) idx.push(i);
if (idx[idx.length - 1] !== len - 1) idx.push(len - 1);
return idx;
}
// ---------- charts ---------- // ---------- charts ----------
const charts = {}; const charts = {};
@ -64,14 +55,15 @@
return o; return o;
} }
function newDs(label, color, extra) { function envelopeDs(name, color, s) {
return Object.assign( s = s || {};
{ label, data: [], borderColor: color, backgroundColor: color, borderWidth: 1.5, pointRadius: 0, tension: 0.25, fill: false }, return [
extra || {} { label: name, data: s.max || [], borderColor: color, backgroundColor: color + "2e", borderWidth: 1, pointRadius: 0, tension: 0, fill: false, order: 0 },
); { label: name + " min", band: true, data: s.min || [], borderColor: color, backgroundColor: color, borderWidth: 1, pointRadius: 0, tension: 0, fill: false, order: 1 },
];
} }
const LEGEND = { display: true, labels: { boxWidth: 10, color: "#7d8a9c" } }; const LEGEND = { display: true, labels: { boxWidth: 10, color: "#7d8a9c", filter: (item, data) => !data.datasets[item.datasetIndex]?.band } };
function initCharts() { function initCharts() {
if (typeof Chart === "undefined") return; if (typeof Chart === "undefined") return;
@ -79,7 +71,7 @@
type: "line", type: "line",
data: { data: {
labels: [], labels: [],
datasets: [newDs("CPU", "#4fc3f7"), newDs("GPU", "#ba68c8")], datasets: [].concat(envelopeDs("CPU", "#4fc3f7"), envelopeDs("GPU", "#ba68c8")),
}, },
options: baseOpts({ max: 100 }), options: baseOpts({ max: 100 }),
}); });
@ -88,7 +80,7 @@
type: "line", type: "line",
data: { data: {
labels: [], labels: [],
datasets: [newDs("Memory", "#81c784"), newDs("VRAM", "#ffb74d")], datasets: [].concat(envelopeDs("Memory", "#81c784"), envelopeDs("VRAM", "#ffb74d")),
}, },
options: baseOpts({ max: 100 }), options: baseOpts({ max: 100 }),
}); });
@ -97,7 +89,7 @@
type: "line", type: "line",
data: { data: {
labels: [], labels: [],
datasets: [newDs("read", "#e57373"), newDs("write", "#fff176")], datasets: [].concat(envelopeDs("read", "#e57373"), envelopeDs("write", "#fff176")),
}, },
options: baseOpts({ options: baseOpts({
ticks: { color: "#7d8a9c", callback: (v) => fmtBytes(v, 0) }, ticks: { color: "#7d8a9c", callback: (v) => fmtBytes(v, 0) },
@ -109,25 +101,21 @@
function updateCharts(hist) { function updateCharts(hist) {
if (!hist || !hist.ts || !hist.ts.length) return; if (!hist || !hist.ts || !hist.ts.length) return;
const ts = hist.ts; const labels = hist.ts.map(fmtTime);
const idx = downsampleIdx(ts.length, 400); const series = hist.series || {};
const pick = (arr) => (arr && idx ? idx.map((i) => (i < arr.length ? arr[i] : null)) : arr); const build = (chart, pairs) => {
const labels = (idx ? idx.map((i) => ts[i]) : ts).map(fmtTime);
const set2 = (chart, keys) => {
chart.data.labels = labels; chart.data.labels = labels;
keys.forEach((k, i) => { chart.data.datasets = [];
chart.data.datasets[i].data = pick(hist.series[k]) || []; pairs.forEach(([name, color, key], i) => {
const ds = envelopeDs(name, color, series[key]);
ds[0].fill = i * 2 + 1;
chart.data.datasets.push(...ds);
}); });
chart.update("none"); chart.update("none");
}; };
set2(charts.cpu, ["cpu", "gpu"]); build(charts.cpu, [["CPU", "#4fc3f7", "cpu"], ["GPU", "#ba68c8", "gpu"]]);
set2(charts.mem, ["mem_pct", "vram_pct"]); build(charts.mem, [["Memory", "#81c784", "mem_pct"], ["VRAM", "#ffb74d", "vram_pct"]]);
build(charts.io, [["read", "#e57373", "io_read"], ["write", "#fff176", "io_write"]]);
charts.io.data.labels = labels;
charts.io.data.datasets[0].data = pick(hist.series.io_read) || [];
charts.io.data.datasets[1].data = pick(hist.series.io_write) || [];
charts.io.update("none");
} }
function pollHistory() { function pollHistory() {