dashboard/app/routers/overview.py

142 lines
4.8 KiB
Python

import asyncio
import math
import socket
import time
from dataclasses import fields
from typing import Any
import psutil
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.collect import net as net_col
from app.config import get_settings
from app.render import render, uptime_str
from app.sample import Sample
router = APIRouter(prefix="/api", tags=["overview"])
RowAgg = dict[str, float | int | None]
def _window(snap: list[Sample], max_points: int) -> list[tuple[float, dict[str, RowAgg]]]:
"""Window-average a sample list down to at most `max_points` points.
The samples are split into consecutive chunks of ceil(n / max_points)
and each numeric Sample field is reduced to {avg, min, max} per chunk;
whole-number fields (byte counts) stay ints, fractional fields are
rounded to 0.1. Each point is stamped with the timestamp of the last
sample in its chunk.
Args:
snap: samples oldest first (HistoryStore.snapshot).
max_points: maximum number of points to emit.
Returns:
(timestamp, field aggregations) pairs, oldest first.
"""
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 f in fields(sample):
if f.name == "ts":
continue
v = getattr(sample, f.name)
if isinstance(v, (int, float)) and not isinstance(v, bool):
vals.setdefault(f.name, []).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].ts, row))
return out
@router.get("/overview")
async def overview(request: Request):
"""Render the Overview tab fragment: current system state card.
Takes the latest sample from the history store (an empty Sample when
none exists yet), derives vram_pct when the collector left it unset,
and adds interface / wifi data and uptime.
Args:
request: FastAPI request (app.state.store).
Returns:
The rendered overview.html as an HTMLResponse.
"""
store = request.app.state.store
s = store.latest() or Sample()
mem_total = s.mem_total or 0
vram_total = s.vram_total or 0
vram_used = s.vram_used
c = {
"cpu": s.cpu,
"cpu_temp": s.cpu_temp,
"load1": s.load1,
"load5": s.load5,
"load15": s.load15,
"mem_used": s.mem_used,
"mem_total": mem_total,
"mem_pct": s.mem_pct,
"swap_used": s.swap_used,
"swap_total": s.swap_total or 0,
"swap_pct": s.swap_pct,
"gpu": s.gpu,
"gpu_name": s.gpu_name,
"gpu_temp": s.gpu_temp,
"vram_used": vram_used,
"vram_total": vram_total,
"vram_pct": s.vram_pct
or ((vram_used / vram_total * 100) if (vram_total and vram_used is not None) else None),
"battery": s.battery,
"battery_status": s.battery_status,
"ac_online": s.ac_online,
"uptime": uptime_str(time.time() - psutil.boot_time()),
"hostname": socket.gethostname(),
"cores": psutil.cpu_count(logical=True) or 1,
**await asyncio.to_thread(net_col.sample),
}
return HTMLResponse(render("overview.html", c=c))
@router.get("/history")
async def history(request: Request):
"""Serve the ring buffer as chart data (JSON).
The buffer is window-averaged via _window() down to at most
`chart_max_points` points. Every key seen in any window gets avg/min/
max arrays, and each array is padded with None for windows that lack
the key (e.g. the GPU fields before a GPU is detected) so the arrays
stay aligned with the ts array — the charts rely on that.
Args:
request: FastAPI request (app.state.store).
Returns:
JSON with ts (unix seconds) and series: key to {avg, min, max}.
"""
snap = _window(request.app.state.store.snapshot(), get_settings().chart_max_points)
ts = [round(t, 1) for t, _ in snap]
keys: set[str] = set()
for _, row in snap:
keys.update(row)
series: dict[str, dict[str, list[Any]]] = {
k: {"avg": [], "min": [], "max": []} for k in keys
}
for _, row in snap:
for k in keys:
agg = row.get(k)
for m in ("avg", "min", "max"):
series[k][m].append(agg[m] if agg else None)
return JSONResponse({"ts": ts, "series": series})