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})