Add homelab dashboards: system, LLM, and UPS on one domain

Three dashboards served by one FastAPI app with a SQLite history store
(raw / 1m / 1h) and hand-written SVG charts: no Prometheus, no Grafana,
no CDN, no build step. Nothing is fetched from the internet at runtime.

* system: a stdlib-only agent (agent/host_agent.py) reports /proc and
  /sys counters; the dashboard turns them into rates over a labelled
  window, so every number says how it was measured.
* llm: gufo/llama.cpp and vLLM /metrics are parsed by an in-house
  exposition parser. Measured delta/elapsed throughput beats a native
  per-request gauge, and each value carries its provenance.
* ups: a NUT wire-protocol client plus health checks that name what
  they looked at. Commands are gated by NUT_ALLOW_ACTIONS,
  DASH_READ_ONLY and a DANGER confirmation, re-checked server-side.

Missing values render as n/a rather than 0, and an unconfigured source
answers 404 {"configured": false} without dialling anything, so "not
deployed here" never looks like an outage.

253 tests at 94% coverage against captured real fixtures; ruff and
pyright clean.

LLM-Generated: true
This commit is contained in:
2026-10-16 17:20:45 -04:00
commit c4bb374ccc
56 changed files with 13018 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Local development overrides. Copy to .env.dev and edit the host names to yours.
# cp .env.dev.example .env.dev
# ./scripts/dev-tunnels.sh start # ssh tunnels to each host's agent
# set -a; . .env.dev; set +a # then run the dashboard
#
# DEV_TUNNELS entries are <local_port>:<ssh_host>:<remote_agent_port>.
# scripts/dev-tunnels.sh forwards each one to 127.0.0.1:<local_port> so the dashboard on
# the laptop reads live fleet data with nothing listening on the hosts.
export DEV_TUNNELS="9121:driveripper-ops:9120 9122:deskwork-ops:9120 9123:gateway-ops:9120"
# Point the dashboard at those tunnels.
export HOST_TARGETS="driveripper=http://127.0.0.1:9121 deskwork=http://127.0.0.1:9122 gateway=http://127.0.0.1:9123"
export GUFO_BASE_URL=http://127.0.0.1:8731
export VLLM_BASE_URL=http://127.0.0.1:8000
export NUT_HOST=127.0.0.1
export NUT_PORT=3493
export DASH_PORT=8090
+71
View File
@@ -0,0 +1,71 @@
# dashboards — configuration. Copy to .env (or set these in the container env file).
# Every value is read once at start; restart after changing any of them.
# --------------------------------------------------------------------------- #
# Server
# --------------------------------------------------------------------------- #
DASH_HOST=127.0.0.1
DASH_PORT=8090
# SQLite file (WAL). Put it on persistent storage in deployment.
DASH_DB=data/dashboards.sqlite3
# DASH_READ_ONLY=1 kills every mutating endpoint regardless of other flags
DASH_READ_ONLY=0
# --------------------------------------------------------------------------- #
# System dashboard: hosts running agent/host_agent.py
# Space or comma separated name=url. A bearer token goes in the URL userinfo
# (http://TOKEN@host:9120) and is never sent to the browser.
# --------------------------------------------------------------------------- #
# HOST_TARGETS=driveripper=http://SECRET@driveripper.example.net:9120 deskwork=http://SECRET@deskwork.example.net:9120
HOST_TARGETS=
HOST_INTERVAL_SECONDS=10
HOST_TIMEOUT_SECONDS=4
# --------------------------------------------------------------------------- #
# LLM dashboard: either engine is optional — unset means "not deployed here"
# and its half of the page disappears instead of showing a dead panel.
# --------------------------------------------------------------------------- #
GUFO_BASE_URL=
GUFO_API_KEY=
VLLM_BASE_URL=
VLLM_API_KEY=
LLM_INTERVAL_SECONDS=2
LLM_TIMEOUT_SECONDS=3
# Window the token rates are measured over. A bare 1s delta jitters because
# prompts arrive in bursts.
RATE_WINDOW_SECONDS=10
# --------------------------------------------------------------------------- #
# UPS dashboard: the NUT data server (upsd)
# Needs upsd reachable: `LISTEN 0.0.0.0` + PORT 3493 in upsd.conf, and in upsd.users
# [dashboards]
# password = "..."
# actions = none # add `actions = -` (or specific cmds) for test buttons
# --------------------------------------------------------------------------- #
NUT_HOST=
NUT_PORT=3493
NUT_USER=
NUT_PASSWORD=
NUT_INTERVAL_SECONDS=5
NUT_TIMEOUT_SECONDS=4
# Instant commands (battery test, beeper, calibration) are the only write path in the
# app. Requires NUT_USER plus this flag; the UI still confirms every command.
NUT_ALLOW_ACTIONS=0
# --------------------------------------------------------------------------- #
# History retention (raw -> 1m rollup -> 1h rollup)
# --------------------------------------------------------------------------- #
RETAIN_RAW_HOURS=48
RETAIN_MINUTE_DAYS=30
RETAIN_HOUR_DAYS=400
# --------------------------------------------------------------------------- #
# Browser poll rates
# --------------------------------------------------------------------------- #
POLL_MS_SYSTEM=10000
POLL_MS_LLM=2000
POLL_MS_UPS=5000
# Debug: DEBUGPY=1 makes the app listen for a debugger on 5678
DEBUGPY=0
DEBUGPY_PORT=5678
+36
View File
@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
*.egg-info/
# Local state: the SQLite history DB and anything else runtime-written
data/
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
# Secrets / local config — never commit. The `.example` files are documentation, so they
# are un-ignored explicitly: `.env.*` would otherwise swallow them and a fresh clone would
# have no annotated list of what to set.
.env
.env.*
!.env.example
!.env.dev.example
# Agent binaries staged for deployment
agent/*.tar.gz
# Editor / OS
.vscode/
.idea/
.DS_Store
# Agent/pipeline scratch (the .agents/ roadmap itself IS tracked)
.agents/phase-sessions/
.agents/pipeline.log
+142
View File
@@ -0,0 +1,142 @@
# Homelab Dashboards
Three dashboards, one domain, one process, one SQLite file:
| Page | What it answers | Source |
| ----------- | ------------------------------------------------------------------------------- | -------------------------------------------------- |
| `/` | Is anything wrong, and which dashboard do I need? | all of the below, one request |
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history of all of it | `agent/host_agent.py`, our own stdlib agent per host |
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) + vLLM `/metrics`, read-only |
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd` on 3493 (its own wire protocol client) |
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app.
## Quickstart
```bash
uv sync --python 3.14 # deps + the package, editable
cp .env.example .env # then edit
uv run dashboards # http://127.0.0.1:8090
uv run uvicorn dashboards.app:app --port 8090 # equivalent
uv run pytest # unit tests, no network needed
```
Developing against the real fleet from a laptop (agents stay unexposed — ssh forwards
them to localhost):
```bash
cp .env.dev.example .env.dev # your ssh aliases + which ports
python3 - <<'PY' # push the agent to each host (once)
import shlex, subprocess, pathlib
for host in ("driveripper-ops", "deskwork-ops", "gateway-ops"):
subprocess.run(["ssh", host, "mkdir -p ~/lab-agent"], check=True)
subprocess.run(["scp", "agent/host_agent.py", f"{host}:~/lab-agent/"], check=True)
PY
./scripts/dev-tunnels.sh start # 9121/9122/9123 -> each host's agent
set -a; . ./.env.dev; set +a
uv run dashboards
```
## The host agent
`agent/host_agent.py` is one file, **stdlib only**, Python 3.10+. It reads `/proc`,
`/sys/block`, `/sys/class/drm` and `statvfs` and answers `GET /snapshot` as JSON.
```bash
python3 host_agent.py --once # print one snapshot, exit
python3 host_agent.py --host 0.0.0.0 --port 9120 --token … # serve, token required
deploy/install-agent.sh # systemd unit (run with sudo)
```
It is read-only by construction: there is no code path that writes, and the HTTP server
only answers `GET`. Counters are reported as counters — the dashboard computes rates over
a labelled window, so the agent never has to remember who polled it last.
## Configuration
Everything comes from the environment (see `.env.example` for the annotated list). At
startup a `.env` next to the checkout is loaded first if it exists — as a *seed*: a
variable the container, systemd unit, or your shell already set always wins. Point
`DASH_ENV_FILE` somewhere else to keep several installs on one box. `.env.example` is
documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev` itself
(see the quickstart) because dev settings must not leak into a production process.
| Variable | Meaning |
| ------------------------------------- | -------------------------------------------------------------- |
| `HOST_TARGETS` | `name=url` per host running the agent; a bearer token may ride in the URL userinfo (`http://TOKEN@host:9120`) |
| `HOST_INTERVAL_SECONDS` | poll interval for hosts (default 10) |
| `GUFO_BASE_URL` / `VLLM_BASE_URL` | LLM engines; unset means *not deployed here*, and its half of the page disappears |
| `RATE_WINDOW_SECONDS` | the Δ window behind every measured token rate (default 10) |
| `NUT_HOST` / `NUT_PORT` | upsd address. Reads work unauthenticated in stock NUT |
| `NUT_USER` / `NUT_PASSWORD` | only needed for instant commands (battery tests) |
| `NUT_ALLOW_ACTIONS` | `1` enables the one write endpoint in the app |
| `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) |
| `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags |
| `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) |
| `RETAIN_RAW_HOURS` / `RETAIN_MINUTE_DAYS` / `RETAIN_HOUR_DAYS` | history rollup and pruning |
A source that is not configured answers `404 {"configured": false}` **without dialling**,
and the page says "not configured" instead of showing a red panel for something you never
installed.
## Architecture
```
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / LIST VAR
▼ ▼ ▼
src/dashboards/collectors.py ────────────────────────────────────────────┐
│ parse + Δcounter/Δt (rates.py) │
▼ ▼
sources/host.py · sources/llm.py · sources/nut.py poller.py (one task per source)
│ samples events │
▼ ▼
store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ── api.py → web/
```
* `poller.py` — every source gets its own asyncio task, its own health record, and its own
error handling. A dead host keeps its last good values and is labelled stale; it never
blanks or blocks another source.
* `store.py` — raw samples roll up to 1-minute and 1-hour averages and the raw rows are
pruned, so a year of history is a few hundred MB rather than a disk emergency.
* `web/` — four static pages, one stylesheet, hand-rolled SVG charts (`charts.js`). No
framework, no CDN, no bundler.
## Testing
```bash
uv run pytest # unit + API tests, fully mocked
uv run pytest --cov # coverage gate
uv run pytest -m live --live # optional: hit the real fleet/engines
uv run ruff check src tests agent && uv run pyright # lint + types
./scripts/capture-fixtures.sh # re-record ground-truth captures
```
Test philosophy: **real captures as fixtures, mocks for behaviour.** Anything that must
match an upstream (`/proc` layouts, vLLM metric names, NUT protocol framing) is asserted
against a verbatim capture in `tests/fixtures/`; behaviour that needs a failure — upsd
refusing a command, an engine returning 500, a host rebooting — is a fake. `-m live`
opt-in tests are the bridge: they run only when you point them at real hardware.
## Battery-test buttons
Instant commands are the only mutating endpoint in the application (`POST /api/ups/command`)
and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
account that actually has `actions`, and a confirmation in the UI — destructive ones
(deep test, calibration, `load.off`) require typing `DANGER`. The server re-checks every
gate; hiding a button is not a safety control. Commands that would cut power are refused
outright while the UPS is already on battery.
## Deployment
`Containerfile` builds a slim image (the agent is copied in too, so one image can serve a
host if you want). In the deployment repo the pattern is the same as every other service:
quadlet container + env template + nginx/SNI on the single domain.
```bash
podman build -t dashboards .
podman run --rm -p 8090:8090 --env-file dashboards.env dashboards
```
Keep the SQLite file on persistent storage (`-v dashboards-data:/var/lib/dashboards`).
+971
View File
@@ -0,0 +1,971 @@
#!/usr/bin/env python3
"""host_agent.py — read-only host metrics agent for the homelab dashboards.
**Stdlib only, one file, Python 3.10+.** Copy it to a host, run it, and the dashboard can
read that host. It never writes anything anywhere: every number comes from reading
/proc, /sys and statvfs, and the HTTP server only answers GET.
GET /health -> {"ok": true, "hostname": ..., "version": ...}
GET /info -> slow-changing identity (os, kernel, cpu model, cores, gpu list)
GET /snapshot -> everything, one JSON document (see SCHEMA below)
GET /schema -> this docstring's SCHEMA section, for self-documenting deploys
Design rules
------------
* **Counters stay counters.** The agent reports cumulative values (jiffies, bytes, ops)
and the *dashboard* computes rates with a labelled window. A stateless agent cannot
lie about an interval, survive a restart silently, or be polled twice by two dashboards
and halve its own rate.
* **Absent is null, never 0.** A host without a GPU, or a sysfs file only root can read,
yields `null`. The UI renders `n/a`. Turning "unknown" into 0 is the one mistake a
monitoring agent is not allowed to make.
* **Every read is guarded.** A vanished /proc entry (process exited mid-scan) skips that
item; the snapshot still succeeds. The poll is degraded, never failed.
* **The only state** is a small pid->cpu-time cache so `processes.top_cpu` can answer a
question /proc cannot (CPU needs two samples). It is bounded and cleared when stale.
SCHEMA (GET /snapshot)
----------------------
schema int -> 1
ts float unix seconds (agent clock)
duration_ms float collection cost
agent_version str
host {hostname, os_pretty, kernel, arch, python, boot_ts, uptime_seconds,
cpu_model, cpu_cores_logical, cpu_cores_physical, container}
cpu {modes: {user,nice,system,idle,iowait,irq,softirq,steal,guest,
guest_nice,total} [clock ticks, cumulative],
cores: [{core, modes: {...}}],
load1, load5, load15, procs_running, procs_blocked,
ctxt_total, processes_total,
freq_mhz, freq_max_mhz}
memory {total_bytes, available_bytes, used_bytes, free_bytes, buffers_bytes,
cached_bytes, dirty_bytes, swap_total_bytes, swap_used_bytes,
committed_bytes}
filesystems [{device, mountpoint, fstype, total_bytes, used_bytes, free_bytes,
used_pct, read_only}]
disk_io {devices: [{name, model, rotational, size_bytes, reads_total,
writes_total, read_bytes_total, write_bytes_total,
in_flight, io_time_ms_total, weighted_io_ms_total,
discard_bytes_total}],
device_names: [str]} # whole disks from /sys/block, no partitions
network {interfaces: [{name, rx_bytes_total, tx_bytes_total, rx_packets_total,
tx_packets_total, rx_errors_total, tx_errors_total,
rx_dropped_total, tx_dropped_total, speed_mbps}]}
gpus [{index, card, vendor, model, pci_slot, pci_address, driver, busy_pct,
vram_total_bytes, vram_used_bytes, gtt_total_bytes, gtt_used_bytes,
power_watts, temps: {label: celsius}, clocks: {label: mhz}}]
temperatures [{kind: hwmon|thermal, chip, label, celsius}]
processes {total, top_rss: [{pid, name, rss_bytes}],
top_cpu: [{pid, name, cpu_pct}]}
errors [str] reads that failed; non-empty means the snapshot is partial
Deployment: see deploy/install-agent.sh (systemd unit, binds 127.0.0.1 unless you pass
--host, requires a bearer token via --token or AGENT_TOKEN when bound off-loopback).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import socket
import threading
import time
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
AGENT_VERSION = "1.0.0"
SCHEMA_VERSION = 1
PAGE_SIZE = 0 # resolved from sysconf at runtime
# Filesystems that are not storage. Mirrors the spirit of node_exporter's default
# --collector.filesystem.fs-types-exclude, trimmed to what actually appears here.
PSEUDO_FSTYPES = {
"autofs", "binfmt_misc", "bpf", "cgroup", "cgroup2", "configfs", "debugfs", "devpts",
"devtmpfs", "efivarfs", "fuse.gvfsd-fuse", "fusectl", "hugetlbfs", "iso9660", "mqueue",
"nsfs", "overlay", "proc", "procfs", "pstore", "ramfs", "rpc_pipefs", "securityfs",
"selinuxfs", "squashfs", "sysfs", "tmpfs", "tracefs", "fuse.lxcfs", "binfmt",
}
PSEUDO_MOUNT_PREFIXES = ("/proc", "/sys", "/dev", "/run", "/var/lib/containers/storage",
"/var/lib/docker", "/snap", "/boot/grub2", "/mnt/iso")
# Interface name prefixes that are containers/bridges talking to themselves.
VIRTUAL_NET_PREFIXES = ("lo", "veth", "docker", "br-", "virbr", "br", "tap", "vnet",
"cni", "kube", "flannel", "dummy", "ifb", "sit", "teql")
# /sys/block entries that are not physical media. dm-* is deliberately here too: an LVM
# or LUKS volume is carved out of a disk we already count, so charting both double-counts.
VIRTUAL_DISK_PREFIXES = ("loop", "ram", "zram", "sr", "dm-", "md127")
# pci slot -> human model, filled on first look (lspci output cannot change at runtime).
_GPU_MODEL_CACHE: dict[str, str | None] = {}
VENDOR_NAMES = {"0x1002": "AMD", "0x10de": "NVIDIA", "0x8086": "Intel", "0x15ad": "VMware",
"0x1af4": "Virtio", "0x1a03": "ASPEED", "0x10ec": "Realtek"}
# sysfs names worth showing as "the CPU temperature", in preference order.
CPU_TEMP_CHIPS = ("k10temp", "zenpower", "coretemp", "cpu_thermal", "soc_thermal", "acpitz")
# Bracket groups in lspci output that name the vendor rather than the product.
_GPU_VENDOR_TAG = re.compile(r"(?:amd|ati|nvidia|intel|apple|matrox|s3|vmware|red hat|xinxiang"
r"|cirrus logic)(?:\s*/\s*(?:amd|ati|nvidia|intel))?", re.I)
# "Advanced Micro Devices, Inc.", "Intel Corporation", "NVIDIA Corporation" …
# The trailing `\b` matters: without it, "VGA compatible controller" splits on the "co" of
# "compatible" and the legend reads "mpatible controller: …".
_GPU_CORP_SUFFIX = re.compile(r"\b(?:Incorporated|Incorporation|Corporation|Company|Limited"
r"|Inc|Corp|Ltd|Co)\b\.?,?\s*", re.I)
# "00:02.0 VGA compatible controller: Intel …" — slot and class are not part of the name.
_GPU_PCI_PREFIX = re.compile(r"^[0-9a-fA-F]{2,4}:[0-9a-fA-F]{2}\.[0-9a-fA-F]\s*[^:]{0,60}:\s*")
def short_gpu_model(name: str | None) -> str | None:
"""Turn an lspci description into something a chart legend can hold.
lspci says `Advanced Micro Devices, Inc. [AMD/ATI] Navi 48 [Radeon AI PRO R9700]`;
the useful product name is the last bracket group, and AMD lists APU aliases separated
by slashes where the last one is the strongest part. The full string is still reported
as `model_full` so nothing is lost — this is display, not identity.
"""
if not name:
return None
groups = re.findall(r"\[([^\]]+)\]", name)
# [AMD/ATI] and [NVIDIA] are vendor tags, not products — keeping them turned an iGPU
# named Phoenix1 into a legend that said "ATI".
products = [g for g in groups if not _GPU_VENDOR_TAG.fullmatch(g.strip())]
if products:
candidate = products[-1].split("/")[-1].strip()
else:
rest = _GPU_PCI_PREFIX.sub("", name)
rest = re.sub(r"\[[^\]]+\]", " ", rest) # drop vendor tags
candidate = _GPU_CORP_SUFFIX.split(rest, maxsplit=1)[-1].strip(" ,;-")
candidate = candidate.strip() or name.strip()
return candidate[:40] + "\u2026" if len(candidate) > 40 else candidate
def _to_float(text: str) -> float | None:
try:
value = float(text)
except (TypeError, ValueError):
return None
return value if value == value and abs(value) != float("inf") else None
def _at(values: list[float | None], index: int) -> float | None:
"""Element `index`, or None. /proc and /sys files are truncated more often than you'd think.
This was a closure over the enclosing loop variable, which is the bug pattern B023 warns
about: escape the loop and it reads the *last* iteration's counters. It takes its data now.
"""
return values[index] if index < len(values) else None
class Collector:
"""Reads one host's /proc and /sys. `root` is injectable so tests can hand it a tree
of fixture files instead of a live machine."""
def __init__(self, root: str | os.PathLike[str] = "/", *, top_processes: int = 5,
net_all: bool = False) -> None:
self.root = Path(root)
self.top_processes = top_processes
self.net_all = net_all
self._lock = threading.Lock() # one HTTP handler at a time may touch the pid cache
self._pid_cpu: dict[int, tuple[float, int, str]] = {} # pid -> (ts, jiffies, name)
self._boot_ts: float | None = None
self._page_size = os.sysconf("SC_PAGE_SIZE") or 4096
self._ticks = os.sysconf("SC_CLK_TCK") or 100
self._cpu_model_cache: str | None = None
# -- tiny fs helpers ----------------------------------------------------- #
def _path(self, *parts: str) -> Path:
return self.root.joinpath(*parts)
def read_text(self, *parts: str) -> str | None:
try:
return self._path(*parts).read_text(errors="replace")
except (OSError, ValueError):
return None
def read_lines(self, *parts: str) -> list[str]:
text = self.read_text(*parts)
return text.splitlines() if text else []
def read_number(self, *parts: str) -> float | None:
"""First number in the file.
`/proc/uptime` is `210036.18 9613750.86`, while `/sys/.../scaling_cur_freq` is bare.
Parsing the whole file as a float returns None for exactly the files that matter,
which reads as "this kernel has no uptime" rather than "you forgot to split".
"""
text = self.read_text(*parts)
if not text:
return None
fields = text.split()
return _to_float(fields[0]) if fields else None
# -- cpu ----------------------------------------------------------------- #
def cpu(self) -> dict[str, Any]:
"""Jiffies per mode, cumulatively since boot. Divide by SC_CLK_TCK for seconds."""
modes: dict[str, float] = {}
cores: list[dict[str, Any]] = []
for line in self.read_lines("proc/stat"):
if not line.startswith("cpu"):
break
fields = line.split()
name = fields[0]
values = [float(v) for v in fields[1:11]] if len(fields) > 1 else []
if not values:
continue
while len(values) < 10:
values.append(0.0)
sample = dict(zip(MODE_NAMES, values[:10], strict=True))
# guest and guest_nice are already counted inside user/nice on Linux
sample["total"] = sum(values[:8])
if name == "cpu":
modes = sample
elif name[3:].isdigit():
cores.append({"core": int(name[3:]), "modes": sample})
load = self.read_text("proc/loadavg")
load_parts = load.split() if load else []
procs_running = procs_blocked = ctxt_total = processes_total = None
for line in self.read_lines("proc/stat"):
if line.startswith("procs_running"):
procs_running = _to_float(line.split()[-1])
elif line.startswith("procs_blocked"):
procs_blocked = _to_float(line.split()[-1])
elif line.startswith("ctxt"):
ctxt_total = _to_float(line.split()[-1])
elif line.startswith("processes"):
processes_total = _to_float(line.split()[-1])
freq = self.cpu_frequency()
return {
"modes": modes or None,
"cores": cores,
"clock_ticks_per_second": self._ticks,
"load1": _to_float(load_parts[0]) if len(load_parts) > 0 else None,
"load5": _to_float(load_parts[1]) if len(load_parts) > 1 else None,
"load15": _to_float(load_parts[2]) if len(load_parts) > 2 else None,
"procs_running": procs_running,
"procs_blocked": procs_blocked,
"ctxt_total": ctxt_total,
"processes_total": processes_total,
"freq_mhz": freq["current"],
"freq_max_mhz": freq["max"],
}
def cpu_frequency(self) -> dict[str, float | None]:
"""Average current / best-case maximum, in MHz."""
current: list[float] = []
maximum: list[float] = []
base = self._path("sys/devices/system/cpu")
if base.is_dir():
for cpu in sorted(base.glob("cpu[0-9]*")):
cur = self.read_number(str(cpu.relative_to(self.root)), "cpufreq/scaling_cur_freq")
if cur is None:
cur = self.read_number(str(cpu.relative_to(self.root)), "cpufreq/cpuinfo_cur_freq")
if cur is not None:
current.append(cur / 1000.0)
mx = self.read_number(str(cpu.relative_to(self.root)), "cpufreq/cpuinfo_max_freq")
if mx is not None:
maximum.append(mx / 1000.0)
return {
"current": (sum(current) / len(current)) if current else None,
"max": max(maximum) if maximum else None,
}
def cpu_model(self) -> str | None:
if self._cpu_model_cache:
return self._cpu_model_cache
for line in self.read_lines("proc/cpuinfo"):
if line.lower().startswith(("model name", "cpu model", "hardware")):
_, _, value = line.partition(":")
if value.strip():
self._cpu_model_cache = value.strip()
return self._cpu_model_cache
# arm64 fallback: compatible = "arm,cortex-a76"
text = self.read_text("proc/device-tree/compatible")
if text:
self._cpu_model_cache = text.strip("\x00").split(",")[-1].strip()
return self._cpu_model_cache
return None
def core_counts(self) -> tuple[int | None, int | None]:
text = self.read_text("proc/cpuinfo") or ""
logical = len(re.findall(r"^processor\s*:", text, re.M)) or (os.cpu_count() or None)
# one block per *thread*, so dedupe (socket, core) — counting how many "core id:"
# lines exist yields 1 for the set of colons, which is how a 24-core TR reports
# "1 physical core" and makes a fleet table quietly meaningless.
seen: set[tuple[str, str]] = set()
for block in text.split("\n\n"):
socket = re.search(r"^physical id\s*:\s*(\d+)", block, re.M)
core = re.search(r"^core id\s*:\s*(\d+)", block, re.M)
if socket and core:
seen.add((socket.group(1), core.group(1)))
physical: int | None = len(seen) or None
if physical is None and logical:
threads = self._threads_per_core()
if threads:
physical = max(1, logical // threads)
return (logical or None), physical
def _threads_per_core(self) -> int | None:
"""SMT width from cpu0's sibling list: `0,24` or `0-1` → 2. VMs often omit it."""
listing = self.read_text("sys/devices/system/cpu/cpu0/topology/thread_siblings_list")
if not listing:
return None
count = 0
for part in listing.strip().split(","):
start, dash, end = part.partition("-")
first = _to_float(start)
if first is None:
continue
last = _to_float(end) if dash else None
count += int((last if last is not None else first) - first) + 1
return count or None
# -- memory -------------------------------------------------------------- #
def memory(self) -> dict[str, float | None]:
raw: dict[str, float] = {}
for line in self.read_lines("proc/meminfo"):
name, _, rest = line.partition(":")
value = _to_float(rest.split()[0]) if rest.split() else None
if value is not None:
raw[name.strip()] = value * 1024.0 # meminfo is in kB
if not raw:
return dict.fromkeys(MEMORY_KEYS)
total = raw.get("MemTotal")
available = raw.get("MemAvailable", raw.get("MemFree"))
swap_total = raw.get("SwapTotal", 0.0)
swap_free = raw.get("SwapFree", 0.0)
out: dict[str, float | None] = {
"total_bytes": total,
"free_bytes": raw.get("MemFree"),
"available_bytes": available,
"used_bytes": (total - available) if (total is not None and available is not None) else None,
"buffers_bytes": raw.get("Buffers"),
"cached_bytes": (raw.get("Cached", 0.0) + raw.get("SReclaimable", 0.0)) or None,
"dirty_bytes": raw.get("Dirty"),
"swap_total_bytes": swap_total,
"swap_used_bytes": swap_total - swap_free,
"committed_bytes": raw.get("Committed_AS"),
}
for key in MEMORY_KEYS:
out.setdefault(key, None)
return out
# -- filesystems --------------------------------------------------------- #
def filesystems(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for line in self.read_lines("proc/mounts"):
parts = line.split()
if len(parts) < 3:
continue
device, mountpoint, fstype = parts[0], parts[1], parts[2]
mountpoint = mountpoint.replace("\\040", " ").replace("\\011", "\t")
if fstype in PSEUDO_FSTYPES:
continue
if any(mountpoint == p or mountpoint.startswith(p + "/") for p in PSEUDO_MOUNT_PREFIXES):
continue
key = (device, mountpoint)
if key in seen:
continue # bind mounts / btrfs subvolume aliases
seen.add(key)
try:
st = os.statvfs(mountpoint)
except OSError:
continue
block = st.f_frsize or st.f_bsize
total = st.f_blocks * block
free = st.f_bavail * block # what an unprivileged writer can use
used = (st.f_blocks - st.f_bfree) * block
denominator = used + free # df's "Use%" basis, not raw total
out.append({
"device": device,
"mountpoint": mountpoint,
"fstype": fstype,
"total_bytes": total or None,
"used_bytes": used,
"free_bytes": free,
"used_pct": (used / denominator * 100.0) if denominator > 0 else None,
"read_only": bool(st.f_flag & os.ST_RDONLY),
})
out.sort(key=lambda item: item["mountpoint"])
return out
# -- disk io ------------------------------------------------------------- #
def disk_io(self) -> dict[str, Any]:
"""Whole disks via /sys/block/<dev>/stat, which is documented ABI-stable.
/proc/diskstats is NOT: the discard/flush counters were inserted at different
offsets in different kernels, so "which index is in-flight" depends on the build.
Measured on driveripper (kernel 7.2): its diskstats line and its /sys/block/stat
line are the same 17 numbers — classic 11 first (in-flight at index 8, matching
/sys/block/<dev>/inflight), then the discard/flush tail. So the classic 11 are
read from fixed indexes and the tail only when the 17-number shape is present.
/sys/block lists whole disks only (nvme0n1, not nvme0n1p1), which is exactly the
set we want: partitions and dm-* layers would double-count the same bytes.
"""
devices: list[dict[str, Any]] = []
names: list[str] = []
base = self._path("sys/block")
if not base.is_dir():
return {"devices": [], "device_names": []}
for entry in sorted(base.iterdir(), key=lambda p: p.name):
name = entry.name
if name.startswith(VIRTUAL_DISK_PREFIXES):
continue
numbers = (self.read_text("sys/block", name, "stat") or "").split()
if len(numbers) < 11:
continue
values = [_to_float(v) for v in numbers]
rotational = self.read_number("sys/block", name, "queue/rotational")
model = (self.read_text("sys/block", name, "device/model") or "").strip() or None
names.append(name)
devices.append({
"name": name,
"model": model,
"rotational": None if rotational is None else rotational == 1.0,
"size_bytes": (self.read_number("sys/block", name, "size") or 0) * 512.0 or None,
"reads_total": _at(values, 0),
"writes_total": _at(values, 4),
"read_bytes_total": (_at(values, 2) or 0.0) * 512.0,
"write_bytes_total": (_at(values, 6) or 0.0) * 512.0,
"in_flight": _at(values, 8),
"io_time_ms_total": _at(values, 9),
"weighted_io_ms_total": _at(values, 10),
# discard/flush tail: only this shape is verified (see docstring)
"discard_bytes_total": ((_at(values, 13) or 0.0) * 512.0
if len(values) >= 17 else None),
})
return {"devices": devices, "device_names": names}
# -- network ------------------------------------------------------------- #
def network(self) -> dict[str, Any]:
interfaces: list[dict[str, Any]] = []
for line in self.read_lines("proc/net/dev"):
name, sep, rest = line.partition(":")
if not sep:
continue
name = name.strip()
if not self.net_all and name.startswith(VIRTUAL_NET_PREFIXES):
continue
fields = [_to_float(v) for v in rest.split()]
if len(fields) < 9:
continue
interfaces.append({
"name": name,
"rx_bytes_total": _at(fields, 0),
"rx_packets_total": _at(fields, 1),
"rx_errors_total": _at(fields, 2),
"rx_dropped_total": _at(fields, 3),
"tx_bytes_total": _at(fields, 8),
"tx_packets_total": _at(fields, 9),
"tx_errors_total": _at(fields, 10),
"tx_dropped_total": _at(fields, 11),
"speed_mbps": self._net_speed(name),
})
interfaces.sort(key=lambda item: item["name"])
return {"interfaces": interfaces}
def _net_speed(self, name: str) -> float | None:
value = self.read_number("sys/class/net", name, "speed")
return value if value and value > 0 else None
# -- gpus ---------------------------------------------------------------- #
def gpus(self) -> list[dict[str, Any]]:
"""AMD GPUs via DRM sysfs (gpu_busy_percent, mem_info_vram_*, power, hwmon temps).
That set exists on amdgpu since kernel ~4.x and is world-readable, which is why
this agent needs no root. NVIDIA/Intel are not wired up: the fleet has no such
card, and guessing at sysfs paths we cannot test would be worse than `null`.
"""
out: list[dict[str, Any]] = []
base = self._path("sys/class/drm")
if not base.is_dir():
return out
for card in sorted(base.glob("card[0-9]*")):
name = card.name
if "-" in name or name == "card": # only cardN, not cardN-DP-1 connectors
continue
device = card / "device"
if not device.is_dir():
continue
busy = self.read_number(str(device.relative_to(self.root)), "gpu_busy_percent")
vram_total = self.read_number(str(device.relative_to(self.root)), "mem_info_vram_total")
vram_used = self.read_number(str(device.relative_to(self.root)), "mem_info_vram_used")
if busy is None and vram_total is None:
continue # not a compute-capable GPU (connector-only / virtual)
pci_id = self._read_uevent(device, "PCI_ID") # "1002:7551"
slot = self._read_uevent(device, "PCI_SLOT_NAME") # "0000:4d:00.0"
vendor_id, _, device_id = (pci_id or "").partition(":")
driver = self._driver_name(device)
full_model = self._gpu_model(slot, vendor_id, device_id)
out.append({
"index": len(out),
"card": name,
"vendor": VENDOR_NAMES.get(f"0x{vendor_id}" if vendor_id else "", vendor_id or None),
"model": short_gpu_model(full_model),
"model_full": full_model,
"pci_slot": slot,
"pci_address": pci_id or None,
"driver": driver,
"busy_pct": busy,
"vram_total_bytes": vram_total,
"vram_used_bytes": vram_used,
# On an APU (Strix Halo) the dedicated carve-out is tiny and the model
# actually lives in GTT (shared system RAM): report both, never silently
# add them into one number that hides which pool was used.
"gtt_total_bytes": self.read_number(str(device.relative_to(self.root)),
"mem_info_gtt_total"),
"gtt_used_bytes": self.read_number(str(device.relative_to(self.root)),
"mem_info_gtt_used"),
"power_watts": self._gpu_power(device),
"temps": self._gpu_temps(device),
"clocks": self._gpu_clocks(device),
})
return out
def _gpu_power(self, device: Path) -> float | None:
"""Board power in watts. hwmon `power*_input`/`*_average`, microWatts, PPT preferred.
Not always present: the amdgpu build on driveripper ships power1_label=PPT with no
matching input file, so this honestly returns None instead of inventing a number.
"""
hwmon_dir = device / "hwmon"
if not hwmon_dir.is_dir():
return None
best: float | None = None
for hwmon in hwmon_dir.glob("hwmon*"):
for suffix in ("input", "average", "max"):
for candidate in sorted(hwmon.glob(f"power*_{suffix}")):
key = candidate.stem.split("_")[0]
label_file = hwmon / f"{key}_label"
label = (self.read_text(str(label_file.relative_to(self.root))) or "").strip().lower()
value = self.read_number(str(candidate.relative_to(self.root)))
if value is None:
continue
watts = value / 1_000_000.0
if best is None:
best = watts
if "ppt" in label or "package" in label:
return watts
return best
def _gpu_clocks(self, device: Path) -> dict[str, float]:
"""Clocks in MHz, keyed by their sysfs label (sclk / mclk)."""
clocks: dict[str, float] = {}
hwmon_dir = device / "hwmon"
if not hwmon_dir.is_dir():
return clocks
for hwmon in hwmon_dir.glob("hwmon*"):
for input_file in sorted(hwmon.glob("freq*_input")):
key = input_file.stem.replace("_input", "")
label_file = hwmon / f"{key}_label"
label = (self.read_text(str(label_file.relative_to(self.root))) or key).strip() \
if label_file.exists() else key
value = self.read_number(str(input_file.relative_to(self.root)))
if value is not None:
clocks[label] = value / 1_000_000.0
return clocks
def _read_uevent(self, device: Path, key: str) -> str | None:
for line in self.read_lines(str(device.relative_to(self.root)), "uevent"):
name, _, value = line.partition("=")
if name == key:
return value.strip().lower()
return None
def _driver_name(self, device: Path) -> str | None:
driver = device / "driver"
try:
return os.path.basename(os.readlink(driver))
except OSError:
return None
def _gpu_model(self, slot: str | None, vendor_id: str | None, device_id: str | None) -> str | None:
"""Human GPU name. lspci when it exists (cached: the answer never changes at
runtime), otherwise vendor + device id — never a bare "gpu"."""
cache_key = slot or f"{vendor_id}:{device_id}"
if cache_key in _GPU_MODEL_CACHE:
return _GPU_MODEL_CACHE[cache_key]
model: str | None = None
if slot:
found = self._lspci_name(slot.split(":", 1)[-1] if slot.count(":") == 2 else slot)
if found:
model = found
if model is None:
vendor = VENDOR_NAMES.get(f"0x{vendor_id}" if vendor_id else "")
if vendor and device_id:
model = f"{vendor} GPU ({vendor_id}:{device_id})"
elif vendor:
model = vendor
_GPU_MODEL_CACHE[cache_key] = model
return model
def _lspci_name(self, slot: str) -> str | None:
"""`lspci -nns <slot>` -> the text after the class, e.g. 'Navi 48 [Radeon ...]'.
Optional: lspci is not guaranteed to be installed, so the caller must cope with
None. The subprocess is bounded and never raises into the snapshot.
"""
try:
import subprocess # noqa: PLC0415 - only needed for one lspci call
raw = subprocess.run(["lspci", "-nns", slot], capture_output=True, text=True,
timeout=3, check=False)
except Exception: # noqa: BLE001 - missing lspci / no permissions is normal
return None
if raw.returncode != 0 or not raw.stdout.strip():
return None
line = raw.stdout.strip().splitlines()[0]
_, _, rest = line.partition(":")
_, _, rest = rest.partition(":")
rest = re.sub(r"\s*\[[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\]", "", rest)
rest = re.sub(r"\s*\(rev [0-9a-fA-F]+\)\s*$", "", rest)
return rest.strip() or None
def _gpu_temps(self, device: Path) -> dict[str, float]:
temps: dict[str, float] = {}
hwmon_dir = device / "hwmon"
if not hwmon_dir.is_dir():
return temps
for hwmon in hwmon_dir.glob("hwmon*"):
for input_file in sorted(hwmon.glob("temp*_input")):
label = input_file.stem.replace("_input", "")
named = hwmon / f"{label}_label"
if named.exists():
label = (self.read_text(str(named.relative_to(self.root))) or label).strip()
value = self.read_number(str(input_file.relative_to(self.root)))
if value is not None:
temps[label] = value / 1000.0
return temps
# -- temperatures -------------------------------------------------------- #
def temperatures(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
hwmon = self._path("sys/class/hwmon")
if hwmon.is_dir():
for unit in sorted(hwmon.glob("hwmon*")):
chip = (self.read_text(str(unit.relative_to(self.root)), "name") or unit.name).strip()
for input_file in sorted(unit.glob("temp*_input")):
key = input_file.stem.replace("_input", "")
label_file = unit / f"{key}_label"
label = chip
if label_file.exists():
label = (self.read_text(str(label_file.relative_to(self.root))) or chip).strip()
value = self.read_number(str(input_file.relative_to(self.root)))
if value is not None:
out.append({"kind": "hwmon", "chip": chip, "label": label,
"celsius": value / 1000.0})
thermal = self._path("sys/class/thermal")
if thermal.is_dir():
for zone in sorted(thermal.glob("thermal_zone*")):
ztype = (self.read_text(str(zone.relative_to(self.root)), "type") or zone.name).strip()
value = self.read_number(str(zone.relative_to(self.root)), "temp")
if value is not None:
out.append({"kind": "thermal", "chip": ztype, "label": ztype,
"celsius": value / 1000.0})
return out
def cpu_temperature(self, temps: list[dict[str, Any]]) -> float | None:
for chip in CPU_TEMP_CHIPS:
picks = [t["celsius"] for t in temps if t["chip"] == chip and t["celsius"] is not None]
if picks:
return max(picks) # Tctl/Tdie style: the hottest sensor on that chip
return None
# -- processes ----------------------------------------------------------- #
def processes(self) -> dict[str, Any]:
"""Top-N by RSS (instant) and by CPU (needs the pid cache — see module docstring)."""
now = time.time()
seen: set[int] = set()
rss_rows: list[dict[str, Any]] = []
cpu_rows: list[dict[str, Any]] = []
total = 0
proc = self._path("proc")
try:
entries = list(proc.iterdir())
except OSError:
return {"total": None, "top_rss": [], "top_cpu": []}
for entry in entries:
if not entry.name.isdigit():
continue
pid = int(entry.name)
stat = self.read_text("proc", entry.name, "stat")
if not stat:
continue
fields = _split_stat(stat)
if not fields:
continue
total += 1
seen.add(pid)
name = fields[0]
utime = _to_float(fields[11]) if len(fields) > 11 else None
stime = _to_float(fields[12]) if len(fields) > 12 else None
jiffies = (utime + stime) if (utime is not None and stime is not None) else None
rss_pages = self._rss_pages(entry.name)
if rss_pages is not None:
rss_rows.append({"pid": pid, "name": name, "rss_bytes": rss_pages * self._page_size})
previous = self._pid_cpu.get(pid)
if jiffies is not None:
if previous is not None and previous[0] < now:
elapsed = now - previous[0]
used = (jiffies - previous[1]) / self._ticks
if elapsed > 0 and used >= 0:
cpu_rows.append({"pid": pid, "name": name,
"cpu_pct": used / elapsed * 100.0})
self._pid_cpu[pid] = (now, jiffies, name)
with self._lock:
for pid in list(self._pid_cpu):
if pid not in seen:
del self._pid_cpu[pid]
rss_rows.sort(key=lambda row: row["rss_bytes"], reverse=True)
cpu_rows.sort(key=lambda row: row["cpu_pct"], reverse=True)
return {"total": total, "top_rss": rss_rows[: self.top_processes],
"top_cpu": cpu_rows[: self.top_processes]}
def _rss_pages(self, pid_dir: str) -> float | None:
"""statm field 2 is resident set size in pages."""
parts = (self.read_text("proc", pid_dir, "statm") or "").split()
return _to_float(parts[1]) if len(parts) > 1 else None
# -- host identity ------------------------------------------------------- #
def host_info(self) -> dict[str, Any]:
uptime = self.read_number("proc/uptime")
if uptime is not None and self._boot_ts is None:
self._boot_ts = time.time() - uptime
release: dict[str, str] = {}
for line in self.read_lines("etc/os-release"):
key, sep, value = line.partition("=")
if sep:
release[key] = value.strip('"')
logical, physical = self.core_counts()
meminfo = self.memory()
try:
uname = os.uname()
except AttributeError: # pragma: no cover - non-POSIX
uname = None
return {
"hostname": socket.gethostname(),
"os_pretty": release.get("PRETTY_NAME") or " ".join(
v for v in (release.get("NAME"), release.get("VERSION")) if v) or None,
"os_id": release.get("ID"),
"kernel": uname.release if uname else None,
"arch": uname.machine if uname else None,
"python": f"{os.sys.version_info.major}.{os.sys.version_info.minor}.{os.sys.version_info.micro}",
"cpu_model": self.cpu_model(),
"cpu_cores_logical": logical,
"cpu_cores_physical": physical,
"memory_total_bytes": meminfo.get("total_bytes"),
"boot_ts": self._boot_ts,
"uptime_seconds": uptime,
"container": self._in_container(),
"agent_version": AGENT_VERSION,
}
def _in_container(self) -> bool:
for marker in ("run/.containerenv", ".dockerenv"):
if self._path(marker).exists():
return True
cgroup = self.read_text("proc/1/cgroup") or ""
return bool(re.search(r"(docker|containerd|kubepods|libpod)", cgroup))
# -- everything ---------------------------------------------------------- #
def snapshot(self, *, top: int | None = None) -> dict[str, Any]:
started = time.perf_counter()
errors: list[str] = []
info = self._safe(self.host_info, "host", errors)
temps = self._safe(self.temperatures, "temperatures", errors) or []
if top is not None:
self.top_processes = top
# no lock here: processes() takes it itself, and threading.Lock is not reentrant
processes = self._safe(self.processes, "processes", errors) or {"total": None,
"top_rss": [],
"top_cpu": []}
return {
"schema": SCHEMA_VERSION,
"agent_version": AGENT_VERSION,
"ts": time.time(),
"duration_ms": round((time.perf_counter() - started) * 1000.0, 2),
"host": info or {},
"cpu": self._safe(self.cpu, "cpu", errors) or {},
"memory": self._safe(self.memory, "memory", errors) or {},
"filesystems": self._safe(self.filesystems, "filesystems", errors) or [],
"disk_io": self._safe(self.disk_io, "disk_io", errors) or {"devices": [], "device_names": []},
"network": self._safe(self.network, "network", errors) or {"interfaces": []},
"gpus": self._safe(self.gpus, "gpus", errors) or [],
"temperatures": temps,
"cpu_temperature_celsius": self.cpu_temperature(temps),
"processes": processes,
"errors": errors,
}
def _safe(self, fn: Callable[[], Any], name: str, errors: list[str]) -> Any:
"""One failing collector degrades its own section to empty; never the snapshot."""
try:
return fn()
except Exception as exc: # noqa: BLE001 - a bad /proc read must not fail the poll
errors.append(f"{name}: {type(exc).__name__}: {exc}")
return None
MODE_NAMES = ("user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal",
"guest", "guest_nice")
MEMORY_KEYS = ("total_bytes", "free_bytes", "available_bytes", "used_bytes", "buffers_bytes",
"cached_bytes", "dirty_bytes", "swap_total_bytes", "swap_used_bytes",
"committed_bytes")
# /proc/<pid>/stat comm is in parens and may itself contain spaces and parens.
_STAT_COMM_RE = re.compile(r"^\d+ \((?P<comm>.*)\) (?P<rest>.*)$", re.S)
def _split_stat(stat: str) -> list[str] | None:
match = _STAT_COMM_RE.match(stat.strip())
if not match:
return None
return [match.group("comm"), *match.group("rest").split()]
# --------------------------------------------------------------------------- #
# HTTP server
# --------------------------------------------------------------------------- #
STATUS_PAGE = """<!doctype html>
<meta charset=utf-8><title>{hostname} — host agent</title>
<style>
body{{font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;background:#0f1115;color:#e6e8ec;
margin:2rem;max-width:46rem}}
h1{{font-size:1.1rem;letter-spacing:.02em}} a{{color:#f7931e}}
table{{border-collapse:collapse}} td{{padding:.15rem .8rem .15rem 0}}
.k{{color:#8b93a7}} code{{background:#1a1e26;padding:.1rem .35rem;border-radius:4px}}
</style>
<h1>{hostname}</h1>
<table>
<tr><td class=k>agent</td><td>v{version}</td></tr>
<tr><td class=k>os</td><td>{os_pretty}</td></tr>
<tr><td class=k>kernel</td><td>{kernel}</td></tr>
<tr><td class=k>cpu</td><td>{cpu_model}</td></tr>
<tr><td class=k>uptime</td><td>{uptime}</td></tr>
</table>
<p><a href="/snapshot">/snapshot</a> · <a href="/info">/info</a> ·
<a href="/health">/health</a> · <a href="/schema">/schema</a></p>
"""
class Handler(BaseHTTPRequestHandler):
server_version = f"host_agent/{AGENT_VERSION}"
collector: Collector
token: str | None = None
def do_GET(self) -> None: # the BaseHTTPRequestHandler API name — keep it
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if self.token and path != "/health":
given = self.headers.get("Authorization", "")
if given != f"Bearer {self.token}":
self._send(401, {"ok": False, "detail": "missing or bad bearer token"})
return
if path == "/":
self._status_page()
elif path == "/health":
self._send(200, {"ok": True, "hostname": socket.gethostname(),
"version": AGENT_VERSION, "ts": time.time()})
elif path == "/info":
self._send(200, {"ok": True, "info": self.collector.host_info(),
"gpus": self.collector.gpus()})
elif path == "/schema":
self._send(200, {"ok": True, "schema": SCHEMA_VERSION, "doc": __doc__})
elif path == "/snapshot":
query = self.path.split("?", 1)[1] if "?" in self.path else ""
top = None
for part in query.split("&"):
if part.startswith("top="):
try:
top = max(0, int(part[4:]))
except ValueError:
top = None
self._send(200, {"ok": True, **self.collector.snapshot(top=top)})
else:
self._send(404, {"ok": False, "detail": f"no such path: {path}"})
def _status_page(self) -> None:
info = self.collector.host_info()
uptime = info.get("uptime_seconds")
body = STATUS_PAGE.format(
hostname=info.get("hostname") or "unknown",
version=AGENT_VERSION,
os_pretty=info.get("os_pretty") or "unknown",
kernel=info.get("kernel") or "unknown",
cpu_model=info.get("cpu_model") or "unknown",
uptime=f"{uptime / 86400:.1f} days" if uptime else "unknown",
)
data = body.encode()
self.send_response(200)
self.send_header("content-type", "text/html; charset=utf-8")
self.send_header("content-length", str(len(data)))
self.send_header("cache-control", "no-store")
self.end_headers()
self.wfile.write(data)
def _send(self, status: int, body: dict[str, Any]) -> None:
data = json.dumps(body, default=str).encode()
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(data)))
self.send_header("cache-control", "no-store")
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt: str, *args: Any) -> None:
# One line per poll, journald-friendly, and never with query strings (a token
# pasted by mistake in a URL should not live in the journal).
if "%s" in fmt:
fmt = fmt % args # the log_message idiom from BaseHTTPRequestHandler
print(f"[agent] {self.address_string()} {fmt}", flush=True)
def serve(host: str, port: int, collector: Collector, token: str | None) -> None:
Handler.collector = collector
Handler.token = token
httpd = ThreadingHTTPServer((host, port), Handler)
print(f"[agent] listening on http://{host}:{port} "
f"({'token required' if token else 'no token'}); pid {os.getpid()}", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("[agent] shutting down", flush=True)
finally:
httpd.server_close()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0],
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--host", default=os.environ.get("AGENT_HOST", "127.0.0.1"),
help="bind address; 0.0.0.0 exposes host metrics to the network")
parser.add_argument("--port", type=int, default=int(os.environ.get("AGENT_PORT", "9120")))
parser.add_argument("--token", default=os.environ.get("AGENT_TOKEN", ""),
help="bearer token required on every request except /health")
parser.add_argument("--root", default="/", help="filesystem root to read (for tests)")
parser.add_argument("--top", type=int, default=5, help="processes per top-N list")
parser.add_argument("--net-all", action="store_true", help="include veth/bridge interfaces")
parser.add_argument("--once", action="store_true", help="print one snapshot as JSON and exit")
args = parser.parse_args(argv)
collector = Collector(args.root, top_processes=args.top, net_all=args.net_all)
if args.once:
print(json.dumps({"ok": True, **collector.snapshot()}, indent=2, default=str))
return 0
if args.host not in ("127.0.0.1", "localhost", "::1") and not args.token:
print("[agent] refusing to bind off-loopback without --token (host metrics are "
"sensitive); pass --token or set AGENT_TOKEN", flush=True)
return 2
serve(args.host, args.port, collector, args.token or None)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+71
View File
@@ -0,0 +1,71 @@
[project]
name = "dashboards"
version = "0.1.0"
description = "Homelab dashboards on one domain: system resources, LLM engines, and UPS battery health"
readme = "README.md"
requires-python = ">=3.13"
# Deliberately small. Everything we collect is either our own agent's JSON (stdlib only,
# see agent/host_agent.py) or an upstream Prometheus exposition parsed by our own parser
# (src/dashboards/prom.py). No prometheus_client, no Grafana, no CDN, no bundler.
dependencies = [
"fastapi>=0.143.3",
"httpx>=0.28.1",
"uvicorn[standard]>=0.54.0",
"pydantic>=2.14.1",
"python-dotenv>=1.2.4",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
"pytest-asyncio>=1.4.0",
"ruff>=0.17.1",
"pyright>=1.1.414",
"debugpy>=1.8.22",
]
# Installed editable by `uv sync` (src layout), so `uv run dashboards` and
# `uv run uvicorn dashboards.app:app` both work without PYTHONPATH gymnastics.
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project.scripts]
dashboards = "dashboards.__main__:main"
[tool.hatch.build.targets.wheel]
packages = ["src/dashboards"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
# auto: async tests run without a decorator on every single one
asyncio_mode = "auto"
filterwarnings = ["error::DeprecationWarning"]
[tool.coverage.run]
source = ["src/dashboards"]
omit = ["tests/*"]
[tool.coverage.report]
show_missing = true
skip_covered = false
[tool.ruff]
line-length = 110
target-version = "py313"
src = ["src", "tests", "agent"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "RUF", "BLE", "PLC"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
# FastAPI's dependency injection is a call in a default argument; that is the API, not a bug.
"src/dashboards/api.py" = ["B008"]
[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.13"
typeCheckingMode = "standard"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Re-record ground-truth captures into tests/fixtures/.
#
# ./scripts/capture-fixtures.sh # needs the tunnels up: scripts/dev-tunnels.sh start
# ./scripts/capture-fixtures.sh --with-llm # also scrape the live engines' /metrics
#
# Fixtures are verbatim, not hand-written: a fixture you typed is a test of your memory.
# When a host's kernel or an engine's metric names change, re-run this and read the diff.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FIX="$ROOT/tests/fixtures"
mkdir -p "$FIX"
HOSTS=(driveripper:9121 deskwork:9122 gateway:9123)
# Engine URLs come from the same env the app uses, so this script carries no deployment
# detail of its own — and never records a tokenised URL into a fixture by accident.
if [ -f "$ROOT/.env.dev" ]; then
set -a; . "$ROOT/.env.dev"; set +a
fi
ENGINES=()
[ -n "${GUFO_BASE_URL:-}" ] && ENGINES+=("gufo:${GUFO_BASE_URL%/}/metrics")
[ -n "${VLLM_BASE_URL:-}" ] && ENGINES+=("vllm:${VLLM_BASE_URL%/}/metrics")
failed=0
for entry in "${HOSTS[@]}"; do
name=${entry%%:*}; port=${entry##*:}
url="http://127.0.0.1:${port}/snapshot"
if curl -fsS --max-time 8 "$url" -o "$FIX/agent_${name}.json"; then
echo " ✓ agent_${name}.json ($(wc -c <"$FIX/agent_${name}.json") bytes)"
else
echo " ✗ agent_${name}.json — no agent on $url (scripts/dev-tunnels.sh start?)"
failed=1
fi
done
if [ "${1:-}" = "--with-llm" ]; then
if [ ${#ENGINES[@]} -eq 0 ]; then
echo " ! no GUFO_BASE_URL / VLLM_BASE_URL set (see .env.dev) — nothing to capture"
failed=1
fi
for entry in "${ENGINES[@]}"; do
name=${entry%%:*}; url=${entry#*:}
if curl -fsS --max-time 20 "$url" -o "$FIX/metrics_${name}.txt"; then
echo " ✓ metrics_${name}.txt ($(wc -l <"$FIX/metrics_${name}.txt") lines)"
else
echo " ✗ metrics_${name}.txt — $url unreachable (VPN? engine down?)"
failed=1
fi
done
else
echo " (skipped engine /metrics captures — pass --with-llm when the engines are reachable)"
fi
# Secrets never belong in a fixture: engines can expose /metrics behind a token in the URL,
# and a hostname you use at home is a hostname in a public repo.
if grep -rlE "Authorization|Bearer |://[^/]*:[^/@]*@" "$FIX" >/dev/null 2>&1; then
echo " ! fixture contains what looks like a credential — scrub it before committing"
failed=1
fi
# Two things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>) and the
# FQDN a host thinks it has. They are replaced deterministically so re-capturing does not
# churn the fixture, and the tests keep the same shapes to assert against.
python3 - "$FIX" <<'PY'
import pathlib
import re
import sys
uuid_re = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
SYNTHETIC = "00000000-0000-4000-8000-" # already-scrubbed markers: re-running must not churn
fix = pathlib.Path(sys.argv[1])
changed = []
for path in sorted(fix.iterdir()):
if path.suffix not in (".json", ".txt"):
continue
text = path.read_text()
seen: dict[str, str] = {}
def swap(match: re.Match[str]) -> str:
value = match.group(0)
if value.startswith(SYNTHETIC):
return value
if value not in seen:
seen[value] = f"00000000-0000-4000-8000-{len(seen) + 1:012d}"
return seen[value]
text = uuid_re.sub(swap, text)
# "hostname": "gw.lab.example.com" -> "gw": the dashboard's host key is the target name
# from HOST_TARGETS, never whatever the box calls itself
text = re.sub(r'("hostname": "[^"]*?)\.[^"]*(")', r"\1\2", text)
if text != path.read_text():
path.write_text(text)
changed.append(path.name)
print(f" scrubbed identifiers in: {', '.join(changed)}" if changed else " fixtures carry no identifiers")
PY
exit "$failed"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Capture a REAL node_exporter exposition from each monitored host into tests/fixtures/.
#
# Runs node_exporter in the foreground on the remote host for a few seconds, scrapes it
# over loopback there, and saves the text. Nothing is installed as a service and no port
# is opened: the only network hop is ssh.
#
# ./scripts/capture-node-exporter.sh # all hosts
# ./scripts/capture-node-exporter.sh driveripper-ops # one host
#
# Why: metric names must be verified against the deployed exporter (see AGENTS.md), never
# guessed from the upstream README.
set -uo pipefail
VERSION="${NODE_EXPORTER_VERSION:-1.12.1}"
OUT="$(cd "$(dirname "$0")/.." && pwd)/tests/fixtures"
HOSTS=("${@:-driveripper-ops deskwork-ops gateway-ops}")
PORT=9199
ARGS="--collector.drm --collector.thermal_zone --collector.cpufreq --collector.hwmon --web.listen-address=127.0.0.1:${PORT}"
mkdir -p "$OUT"
command -v ssh >/dev/null || { echo "ssh not found"; exit 1; }
for host in $HOSTS; do
echo "=== $host"
ssh -n -o ConnectTimeout=10 "$host" "set -e
mkdir -p \$HOME/exporters && cd \$HOME/exporters
if [ ! -x node_exporter ]; then
curl -sSL -o ne.tgz https://github.com/prometheus/node_exporter/releases/download/v${VERSION}/node_exporter-${VERSION}.linux-amd64.tar.gz
tar xzf ne.tgz && mv node_exporter-${VERSION}.linux-amd64/node_exporter .
rm -rf ne.tgz node_exporter-${VERSION}.linux-amd64
fi
timeout 8 ./node_exporter ${ARGS} >/dev/null 2>&1 &
sleep 3
curl -s --max-time 5 127.0.0.1:${PORT}/metrics
" > "$OUT/node_exporter_${host}.txt" 2>/dev/null
lines=$(wc -l < "$OUT/node_exporter_${host}.txt")
if [ "$lines" -lt 20 ]; then
echo " FAILED (only $lines lines) — check ssh access to $host"
rm -f "$OUT/node_exporter_${host}.txt"
continue
fi
echo " saved $lines lines -> tests/fixtures/node_exporter_${host}.txt"
echo " gpu: $(grep -cE '^node_gpu' "$OUT/node_exporter_${host}.txt") node_gpu series"
grep -oE '^node_gpu[a-z_]*' "$OUT/node_exporter_${host}.txt" | sort -u | sed 's/^/ /' | head -20
done
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Local development server: start|stop|status|restart|logs|tunnel-check
#
# ./scripts/dev-server.sh restart # reads .env.dev if present, runs on DASH_PORT
#
# The server keeps running after this shell exits (nohup + pidfile) so you can browse it
# while an agent works. Logs: data/logs/dev.log
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
PIDFILE="$ROOT/data/dev-server.pid"
LOG="$ROOT/data/logs/dev.log"
mkdir -p "$ROOT/data/logs"
running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; }
start() {
if running; then
echo "already running (pid $(cat "$PIDFILE")) — http://127.0.0.1:${DASH_PORT:-8090}"
return 0
fi
if [ -f "$ROOT/.env.dev" ]; then
set -a; . "$ROOT/.env.dev"; set +a
echo "loaded .env.dev"
fi
PORT="${DASH_PORT:-8090}"
nohup .venv/bin/dashboards >"$LOG" 2>&1 &
echo $! >"$PIDFILE"
sleep 3
if running && curl -sf -o /dev/null "http://127.0.0.1:${PORT}/api/health"; then
echo "started pid $(cat "$PIDFILE") — http://127.0.0.1:${PORT}"
else
echo "FAILED to start; last lines of $LOG:"
tail -20 "$LOG"
return 1
fi
}
stop() {
if running; then
kill "$(cat "$PIDFILE")" 2>/dev/null
sleep 1
echo "stopped"
else
echo "not running"
fi
rm -f "$PIDFILE"
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status)
if running; then
echo "running pid $(cat "$PIDFILE")"
curl -s "http://127.0.0.1:${DASH_PORT:-8090}/api/health" \
| .venv/bin/python -c "import json,sys; d=json.load(sys.stdin); print('status:', d['status']); [print(' ', s['key'], 'ok' if s['ok'] else 'DOWN', (s['error'] or '')[:90]) for s in d['sources']]"
else
echo "not running"
fi ;;
logs) tail -n 40 "$LOG" ;;
*) echo "usage: $0 start|stop|restart|status|logs"; exit 2 ;;
esac
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Dev-only: run agent/host_agent.py on each monitored host and forward it to localhost,
# so `make dev` on the laptop sees live fleet data without opening a single port.
#
# ./scripts/dev-tunnels.sh start|stop|status|restart
#
# Each tunnel is ssh -L <local>:127.0.0.1:<remote-port> <host> 'python3 agent/host_agent.py'
# and restarts itself if the link drops. Production is the opposite: deploy/install-agent.sh
# installs a systemd unit that binds the LAN address with a bearer token, and the dashboard
# dials it directly.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
RUN="$ROOT/data/tunnels"
LOGS="$ROOT/data/logs"
# Where the agent lives on each host. `~` is written into the generated script literally so
# the REMOTE shell expands it (a local $HOME would be the wrong path over there).
AGENT_REMOTE_DIR="${AGENT_REMOTE_DIR:-~/lab-agent}"
AGENT_COMMAND="${AGENT_COMMAND:-python3 host_agent.py}"
# Targets come from .env.dev (see .env.dev.example) — never hardcoded here, so this file
# stays portable and holds no hostnames of ours.
[ -f "$ROOT/.env.dev" ] && . "$ROOT/.env.dev"
DEV_TUNNELS="${DEV_TUNNELS:-}"
if [ -z "$DEV_TUNNELS" ]; then
echo "no DEV_TUNNELS set — copy .env.dev.example to .env.dev first"; exit 2
fi
TUNNELS=()
for entry in $DEV_TUNNELS; do
# 9121:driveripper-ops:9120 -> "9121 driveripper-ops 9120"
TUNNELS+=("$(echo "$entry" | tr ':' ' ')")
done
mkdir -p "$RUN" "$LOGS"
start() {
for spec in "${TUNNELS[@]}"; do
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
pidfile="$RUN/$host.pid"
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
echo " $host already tunnelling on :$local (pid $(cat "$pidfile"))"
continue
fi
# A previous agent survives the ssh session that started it, and the next agent then
# dies on bind() with "address already in use" — which looks like a broken agent rather
# than a leaked process. Clear it in a SEPARATE ssh session: putting pkill in the same
# command line as the agent makes pkill match that very command line and shoot itself.
cat > "$RUN/$host.sh" <<EOF
#!/usr/bin/env bash
# generated by scripts/dev-tunnels.sh — do not edit, it is rewritten on every start
while true; do
ssh -n -o BatchMode=yes "$host" \
"pkill -f '[h]ost_agent.py --host 127.0.0.1 --port $remote'" >/dev/null 2>&1
sleep 1
ssh -nT -o ServerAliveInterval=30 -o ServerAliveCountMax=4 -o ExitOnForwardFailure=yes \
-L $local:127.0.0.1:$remote "$host" \
"cd $AGENT_REMOTE_DIR && exec $AGENT_COMMAND --host 127.0.0.1 --port $remote"
echo "[tunnel] $host link dropped at \$(date -Is) (exit \$?); retrying in 5s"
sleep 5
done
EOF
chmod +x "$RUN/$host.sh"
nohup "$RUN/$host.sh" >>"$LOGS/tunnel-$host.log" 2>&1 &
echo $! >"$pidfile"
echo " $host -> http://127.0.0.1:$local (pid $!)"
done
}
stop() {
for spec in "${TUNNELS[@]}"; do
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
pidfile="$RUN/$host.pid"
[ -f "$pidfile" ] || continue
pid=$(cat "$pidfile")
pkill -P "$pid" 2>/dev/null
kill "$pid" 2>/dev/null
# -N would mean "no remote command", so the forward is kept by a plain ssh running the
# agent; kill any leftover forward for this host by pattern, not just by parent.
pkill -f "ssh -nT.*-L $local:127.0.0.1:$remote $host" 2>/dev/null
# The remote agent outlives its ssh session, and the next one then dies on bind() with
# "address already in use" — which looks like a broken agent, not a leaked process.
timeout 10 ssh -n "$host" "pkill -f '[h]ost_agent.py --host 127.0.0.1 --port $remote'" \
>/dev/null 2>&1
rm -f "$pidfile"
echo " stopped $host"
done
}
status() {
for spec in "${TUNNELS[@]}"; do
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
body=$(curl -s --max-time 4 "http://127.0.0.1:$local/health" || true)
if [ -n "$body" ]; then
echo " OK $host http://127.0.0.1:$local $body"
else
echo " DOWN $host http://127.0.0.1:$local"
fi
done
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
restart) stop; sleep 1; start ;;
status) status ;;
*) echo "usage: $0 start|stop|status|restart"; exit 2 ;;
esac
+10
View File
@@ -0,0 +1,10 @@
"""Homelab dashboards: system resources, LLM engines, and UPS battery health.
One FastAPI app, one domain, one SQLite file. See AGENTS.md for the rules and
.agent(s)/PLAN.md for the locked decisions.
"""
from .config import SETTINGS
__version__ = "0.1.0"
__all__ = ["SETTINGS", "__version__"]
+28
View File
@@ -0,0 +1,28 @@
"""`python -m dashboards` — the same server as `uv run uvicorn dashboards.app:app`.
DASH_PORT=8090 DEBUGPY=1 python -m dashboards
"""
from __future__ import annotations
import os
from .app import create_app
from .config import SETTINGS
from .debug import maybe_listen
def main() -> None:
import uvicorn # noqa: PLC0415 - only the entry point needs uvicorn
maybe_listen()
reload = os.environ.get("DASH_RELOAD", "").lower() in ("1", "true", "yes", "on")
if reload:
uvicorn.run("dashboards.app:app", host=SETTINGS.host, port=SETTINGS.port, reload=True)
else:
# pass the object: create_app() is cheap and avoids a second import of config
uvicorn.run(create_app(), host=SETTINGS.host, port=SETTINGS.port)
if __name__ == "__main__":
main()
+399
View File
@@ -0,0 +1,399 @@
"""HTTP API. GET everywhere except one endpoint: `POST /api/ups/command`.
Everything a browser can see is derived from the in-memory payloads (fast, no DB on the
hot path) plus SQLite for anything older than a few minutes. Two rules from llm_monitor that
are worth restating here because they are easy to break by accident:
* `/api/config` never leaks a token or password — target URLs are passed through
`Target.display_url`, which strips userinfo.
* an unconfigured source answers 404 `{"configured": false}` without dialling anything, so
the page can tell "not deployed here" apart from "deployed and broken".
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from .collectors import NutMonitor
from .config import Settings
from .poller import Hub
from .sources import nut
from .sources.host import fleet_summary
from .sources.llm import fleet_totals
from .store import Event, SampleStore
NO_STORE = {"cache-control": "no-store, max-age=0"}
RANGE_SECONDS = {"5m": 300, "15m": 900, "30m": 1800, "1h": 3600, "3h": 10_800,
"6h": 21_600, "12h": 43_200, "24h": 86_400, "7d": 604_800,
"30d": 2_592_000, "90d": 7_776_000}
@dataclass
class AppContext:
"""Everything the routes need, assembled once at startup and passed around."""
settings: Settings
store: SampleStore
hub: Hub
nut_monitor: NutMonitor | None = None
started_at: float = 0.0
def get_context(request: Request) -> AppContext:
"""The context built during lifespan. FastAPI hands it to every route."""
return request.app.state.ctx # type: ignore[no-any-return]
def parse_range(value: str) -> float:
"""`1h` / `7d` / `45m` / raw seconds. Unknown values are a 400, not a silent default:
a chart that quietly shows the wrong window is worse than an error."""
raw = (value or "1h").strip().lower()
if raw in RANGE_SECONDS:
return float(RANGE_SECONDS[raw])
if raw[-1] in ("s", "m", "h", "d") and raw[:-1].isdigit():
multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86_400}[raw[-1]]
return float(int(raw[:-1]) * multiplier)
if raw.isdigit():
return float(raw)
raise HTTPException(400, f"unknown range {value!r}; try 5m 1h 24h 7d 30d")
def _history(store: SampleStore, source: str, host: str, metric: str, span: float,
points: int) -> dict[str, Any]:
series = store.history(source, host, metric, span=span, max_points=points)
return {
"source": source,
"host": host,
"metric": metric,
"range_seconds": span,
"points": [{"ts": ts, "value": value} for ts, value in series],
}
# --------------------------------------------------------------------------- #
# Common routes
# --------------------------------------------------------------------------- #
def common_router() -> APIRouter:
router = APIRouter(prefix="/api", tags=["meta"])
@router.get("/config")
async def config(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
settings = ctx.settings
return {
"app": {"name": "dashboards", "version": "0.1.0",
"started_at": ctx.started_at, "read_only": settings.read_only},
"system": {
"enabled": bool(settings.host_targets),
"interval_seconds": settings.host_interval,
"poll_ms": settings.poll_ms_system,
"targets": [{"name": t.name, "url": t.display_url} for t in settings.host_targets],
"target_problems": list(settings.host_target_problems),
},
"llm": {
"gufo": {"enabled": bool(settings.gufo_url), "base_url": settings.gufo_url,
"auth": bool(settings.gufo_api_key)},
"vllm": {"enabled": bool(settings.vllm_url), "base_url": settings.vllm_url,
"auth": bool(settings.vllm_api_key)},
"interval_seconds": settings.llm_interval,
"poll_ms": settings.poll_ms_llm,
"rate_window_seconds": settings.rate_window,
},
"ups": {
"enabled": settings.nut_enabled,
"server": f"{settings.nut_host}:{settings.nut_port}" if settings.nut_enabled else None,
"auth": bool(settings.nut_user),
"interval_seconds": settings.nut_interval,
"poll_ms": settings.poll_ms_ups,
"actions_enabled": settings.actions_allowed,
"simulated": bool(ctx.nut_monitor and ctx.nut_monitor.simulated),
},
"ranges": sorted(RANGE_SECONDS, key=lambda key: RANGE_SECONDS[key]),
}
@router.get("/health")
async def health(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
states = ctx.hub.states()
enabled = [s for s in states if s["configured"]]
healthy = [s for s in enabled if s["ok"]]
status = "ok" if enabled and len(healthy) == len(enabled) else (
"degraded" if healthy else ("empty" if not enabled else "down"))
# a fresh install with nothing configured is empty, not broken — `ok` answers "is the
# app working", the banner on the page answers "is there anything to look at"
return {"ok": status in ("ok", "empty"), "status": status, "sources": states,
"db": ctx.store.stats(), "uptime_seconds": time.time() - ctx.started_at}
@router.get("/events")
def events(limit: int = Query(60, ge=1, le=500), source: str | None = None,
since: float | None = None,
ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""Event log (transitions, UPS state changes, actions taken).
Served by a sync handler on purpose: SQLite reads belong in Starlette's
threadpool, not on the event loop the collectors share.
"""
rows = ctx.store.recent_events(limit, source=source, since=since)
return {"events": [row.as_json() for row in rows]}
@router.get("/history")
def history(source: str = Query(...), host: str = Query(...), metric: str = Query(...),
range: str = Query("1h"), points: int = Query(600, ge=10, le=4000),
ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
return _history(ctx.store, source, host, metric, parse_range(range), points)
@router.get("/metrics")
def metrics(source: str | None = None, host: str | None = None,
ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""Which series actually have history — the honest answer to "why is this chart empty"."""
return {"metrics": ctx.store.series_names(source, host)}
@router.get("/summary")
async def summary(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""One call for the landing page: is each dashboard alive, and what stands out."""
hosts = [job.state.payload for job in ctx.hub.jobs.values()
if job.dashboard == "system" and job.state.payload]
engines = [job.state.payload for job in ctx.hub.jobs.values()
if job.dashboard == "llm" and job.state.payload]
ups_payload = None
nut_job = ctx.hub.jobs.get("nut")
if nut_job and nut_job.state.payload:
ups_payload = nut_job.state.payload
return {
"ts": time.time(),
"system": {"enabled": bool(ctx.settings.host_targets),
"fleet": fleet_summary(hosts) if hosts else None,
"hosts": [{"name": h["name"], "os": (h.get("info") or {}).get("os_pretty"),
"cpu_usage_pct": h["cpu"]["usage_pct"],
"load1": h["cpu"].get("load1"),
"cores": (h.get("info") or {}).get("cpu_cores_logical"),
"mem_used_pct": h["memory"]["used_pct"],
"disk_worst_used_pct": h["disks"]["worst_used_pct"],
"gpu_busy_pct": [g.get("busy_pct") for g in h.get("gpus") or []],
"temp_c": h["cpu"].get("temp_c"),
"uptime_seconds": (h.get("info") or {}).get("uptime_seconds")}
for h in hosts],
"states": ctx.hub.states("system")},
"llm": {"enabled": bool(ctx.settings.gufo_url or ctx.settings.vllm_url),
"fleet": fleet_totals(engines) if engines else None,
"engines": [{"engine": e["engine"],
"decode_tokens_per_second": e["metrics"].get(
"decode_tokens_per_second", {}).get("value"),
"active": e["sessions"].get("active")}
for e in engines],
"states": ctx.hub.states("llm")},
"ups": {"enabled": ctx.settings.nut_enabled,
"simulated": bool(ups_payload and ups_payload.get("simulated")),
"ups": [{"name": card["name"], "overall": card["health"]["overall"],
"status": card["status"],
"charge_pct": card["numbers"].get("charge_pct"),
"load_pct": card["numbers"].get("load_pct"),
"runtime_seconds": card["numbers"].get("battery_runtime")}
for card in (ups_payload or {}).get("ups", [])],
"states": ctx.hub.states("ups")},
"recent_events": [event.as_json() for event in ctx.store.recent_events(12)],
}
return router
# --------------------------------------------------------------------------- #
# System dashboard
# --------------------------------------------------------------------------- #
def system_router() -> APIRouter:
router = APIRouter(prefix="/api/system", tags=["system"])
@router.get("/snapshot")
async def snapshot(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
if not ctx.settings.host_targets:
raise HTTPException(404, detail={"configured": False,
"detail": "no hosts configured — set HOST_TARGETS"})
hosts = []
for job in ctx.hub.jobs.values():
if job.dashboard != "system":
continue
payload = job.state.payload
if payload is None:
# keep an unreachable host in the list: absent from the list and present-but-
# red are different statements, and only one of them is a config problem
payload = {"name": job.label, "ts": job.state.last_attempt, "unreachable": True}
hosts.append(payload)
hosts.sort(key=lambda item: item.get("name") or "")
return {"ts": time.time(), "hosts": hosts, "fleet": fleet_summary(hosts),
"states": ctx.hub.states("system"),
"window_seconds": ctx.settings.host_interval}
@router.get("/history")
def history(metrics: str = Query("cpu.usage_pct"), hosts: str = Query(""),
range: str = Query("1h"), points: int = Query(600, ge=10, le=4000),
ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""One request for a whole chart: N hosts x M metrics.
`hosts` is comma-separated, or empty for every configured target. Charting three
hosts should cost one round trip, not three, because the browser polls this.
"""
span = parse_range(range)
wanted_metrics = [item.strip() for item in metrics.split(",") if item.strip()]
wanted_hosts = [item.strip() for item in hosts.split(",") if item.strip()] or [
target.name for target in ctx.settings.host_targets]
series = [_history(ctx.store, "host", host, metric, span, points)
for host in wanted_hosts for metric in wanted_metrics]
return {"hosts": wanted_hosts, "range_seconds": span, "series": series}
return router
# --------------------------------------------------------------------------- #
# LLM dashboard
# --------------------------------------------------------------------------- #
def llm_router() -> APIRouter:
router = APIRouter(prefix="/api/llm", tags=["llm"])
@router.get("/snapshot")
async def snapshot(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
if not (ctx.settings.gufo_url or ctx.settings.vllm_url):
raise HTTPException(404, detail={"configured": False,
"detail": "no engines configured — set "
"GUFO_BASE_URL / VLLM_BASE_URL"})
engines = []
for job in ctx.hub.jobs.values():
if job.dashboard != "llm":
continue
payload = job.state.payload or {
"engine": job.label, "metrics": {}, "sessions": {"active": None, "list": []},
"unreachable": True}
engines.append(payload)
return {"ts": time.time(), "engines": engines, "fleet": fleet_totals(engines),
"states": ctx.hub.states("llm")}
@router.get("/history")
def history(metrics: str = Query("decode_tokens_per_second"), engines: str = Query(""),
range: str = Query("1h"), points: int = Query(600, ge=10, le=4000),
ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
span = parse_range(range)
wanted_metrics = [item.strip() for item in metrics.split(",") if item.strip()]
wanted_engines = [item.strip() for item in engines.split(",") if item.strip()] or [
name for name, url in (("gufo", ctx.settings.gufo_url), ("vllm", ctx.settings.vllm_url)) if url]
return {"engines": wanted_engines, "range_seconds": span,
"series": [_history(ctx.store, "llm", engine, f"llm.{metric}", span, points)
for engine in wanted_engines for metric in wanted_metrics]}
return router
# --------------------------------------------------------------------------- #
# UPS dashboard
# --------------------------------------------------------------------------- #
def ups_router() -> APIRouter:
router = APIRouter(prefix="/api/ups", tags=["ups"])
def require_monitor(ctx: AppContext) -> NutMonitor:
if ctx.nut_monitor is None or not ctx.settings.nut_enabled:
raise HTTPException(404, detail={"configured": False,
"detail": "no NUT server configured — set NUT_HOST"})
return ctx.nut_monitor
@router.get("/snapshot")
async def snapshot(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
monitor = require_monitor(ctx)
job = ctx.hub.jobs.get("nut")
payload = (job.state.payload if job else None) or {
"ts": time.time(), "ups": [], "simulated": monitor.simulated,
"actions_enabled": ctx.settings.actions_allowed}
events = ctx.store.recent_events(40, source="ups")
return {**payload, "states": ctx.hub.states("ups"),
"events": [event.as_json() for event in events]}
@router.get("/history")
def history(metrics: str = Query("ups.charge_pct"), ups: str = Query(""),
range: str = Query("24h"), points: int = Query(600, ge=10, le=4000),
ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
span = parse_range(range)
wanted_metrics = [item.strip() for item in metrics.split(",") if item.strip()]
job = ctx.hub.jobs.get("nut")
wanted_ups = [item.strip() for item in ups.split(",") if item.strip()] or [
card["name"] for card in ((job.state.payload if job else None) or {}).get("ups", [])]
return {"ups": wanted_ups, "range_seconds": span,
"series": [_history(ctx.store, "ups", name, metric, span, points)
for name in wanted_ups for metric in wanted_metrics]}
@router.get("/commands")
async def commands(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""What may be sent right now, and why not when it may not."""
require_monitor(ctx)
job = ctx.hub.jobs.get("nut")
payload = (job.state.payload if job else None) or {"ups": []}
return {"actions_enabled": ctx.settings.actions_allowed,
"read_only": ctx.settings.read_only,
"risk_levels": [nut.RISK_SAFE, nut.RISK_CAUTION, nut.RISK_DANGEROUS],
"catalog": [{"command": key, **value} for key, value in nut.COMMANDS.items()],
"ups": [{"name": card["name"], "commands": card["commands"]}
for card in payload.get("ups", [])]}
@router.post("/command")
async def send_command(ctx: AppContext = Depends(get_context),
payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""The one write endpoint in the application. Deliberately loud about it.
Gated on: NUT_ALLOW_ACTIONS, not DASH_READ_ONLY, credentials that carry `actions`
in upsd.users, the command being advertised by the driver for that UPS, and — for
anything that can cut power — the browser echoing the confirmation word back.
"""
monitor = require_monitor(ctx)
ups_name = str(payload.get("ups") or "").strip()
command = str(payload.get("command") or "").strip()
confirm = str(payload.get("confirm") or "").strip()
if not ups_name or not command:
raise HTTPException(400, "both `ups` and `command` are required")
if not ctx.settings.actions_allowed:
raise HTTPException(403, "UPS actions are disabled — set NUT_ALLOW_ACTIONS=1 "
"(and don't run with DASH_READ_ONLY=1)")
job = ctx.hub.jobs.get("nut")
card = next((item for item in ((job.state.payload or {}).get("ups", []) if job else [])
if item["name"] == ups_name), None)
if card is None:
raise HTTPException(404, f"unknown UPS {ups_name!r} — not in the last poll")
advice = nut.command_advice(command)
if advice is None:
raise HTTPException(400, f"{command} is not a command this dashboard will send")
if command not in card.get("advertised_commands", []):
raise HTTPException(400, f"the driver for {ups_name} does not advertise {command}")
if advice["risk"] == nut.RISK_DANGEROUS and confirm != "DANGER":
raise HTTPException(422, f"{advice['label']} can cut power: send "
f"confirm=\"DANGER\" to prove you read the warning")
if any(item["command"] == command and not item["enabled"] for item in card["commands"]):
blocked = next(item for item in card["commands"] if item["command"] == command)
raise HTTPException(409, blocked["reason"] or f"{command} is not available")
try:
if monitor.simulated:
detail = monitor.simulator.command(ups_name, command) # type: ignore[union-attr]
else:
async with nut.NutClient(
ctx.settings.nut_host, ctx.settings.nut_port,
timeout=ctx.settings.nut_timeout,
username=ctx.settings.nut_user, password=ctx.settings.nut_password,
) as client:
await client.login()
detail = await client.instcmd(ups_name, command)
except nut.NutError as exc:
ctx.store.log_event(Event(time.time(), "ups", ups_name, "action", "error",
f"{advice['label']} on {ups_name} failed: {exc}",
{"command": command, "code": exc.code}))
raise HTTPException(502, f"upsd refused {command}: {exc}") from exc
ctx.store.log_event(_action_event(ups_name, command, advice, detail))
return {"ok": True, "ups": ups_name, "command": command, "detail": detail,
"risk": advice["risk"], "simulated": monitor.simulated,
"hint": "watch the state card — the driver answers over the next few polls"}
return router
def _action_event(ups_name: str, command: str, advice: dict[str, str], detail: str) -> Event:
severity = "warn" if advice["risk"] != nut.RISK_SAFE else "info"
return Event(time.time(), "ups", ups_name, "action", severity,
f"{advice['label']} sent to {ups_name}: {detail}", {"command": command})
+178
View File
@@ -0,0 +1,178 @@
"""Application assembly: one FastAPI app, four pages, one SQLite file, N poll loops.
`create_app()` takes injectable settings and store so tests can build an app with an
in-memory DB and no targets — the same code path production uses, without the network.
"""
from __future__ import annotations
import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from .api import NO_STORE, AppContext, common_router, llm_router, system_router, ups_router
from .collectors import NutMonitor, UpstreamError, host_jobs, llm_jobs, nut_job
from .config import SETTINGS as DEFAULT_SETTINGS
from .config import Settings
from .poller import Hub
from .rates import RateEstimator
from .sources.host import HostCollector
from .sources.nut_sim import NutSimulator
from .store import SampleStore
WEB = Path(__file__).resolve().parent / "web"
PAGES = {"/": "index.html", "/system": "system.html", "/llm": "llm.html", "/ups": "ups.html"}
VERSION = "0.1.0"
def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
AppContext, SampleStore, Callable[[], None]]:
"""Create the store, the collectors, and the hub's jobs. Returns (ctx, store, start)."""
store = SampleStore(
settings.db_path,
raw_keep_hours=settings.raw_keep_hours,
minute_keep_days=settings.minute_keep_days,
hour_keep_days=settings.hour_keep_days,
)
rates = RateEstimator(span=settings.rate_window)
host_collector = HostCollector(rates)
nut_monitor: NutMonitor | None = None
if settings.nut_enabled:
simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None
nut_monitor = NutMonitor(settings, simulator=simulator)
hub = Hub(store)
for job in host_jobs(list(settings.host_targets), client, host_collector,
settings.host_interval):
hub.add(job)
for job in llm_jobs(settings, client, rates):
hub.add(job)
if nut_monitor is not None:
hub.add(nut_job(settings, nut_monitor, client))
ctx = AppContext(settings=settings, store=store, hub=hub, nut_monitor=nut_monitor,
started_at=time.time())
return ctx, store, hub.start
def _truthy(value: str | None) -> bool:
return (value or "").strip().lower() in ("1", "true", "yes", "on")
def create_app(settings: Settings | None = None, store: SampleStore | None = None) -> FastAPI:
"""`store` is for tests: when supplied it is not created and not closed here."""
settings = settings or DEFAULT_SETTINGS
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
owned_store: SampleStore | None = store
ctx: AppContext | None = None
client = httpx.AsyncClient(
timeout=httpx.Timeout(max(settings.host_timeout, settings.llm_timeout)),
follow_redirects=False,
limits=httpx.Limits(max_connections=32, max_keepalive_connections=16),
)
try:
if store is not None:
# injected: rebuild everything except the DB
rates = RateEstimator(span=settings.rate_window)
hub = Hub(store)
nut_monitor = None
if settings.nut_enabled:
simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None
nut_monitor = NutMonitor(settings, simulator=simulator)
for job in host_jobs(list(settings.host_targets), client,
HostCollector(rates), settings.host_interval):
hub.add(job)
for job in llm_jobs(settings, client, rates):
hub.add(job)
if nut_monitor is not None:
hub.add(nut_job(settings, nut_monitor, client))
ctx = AppContext(settings=settings, store=store, hub=hub,
nut_monitor=nut_monitor, started_at=time.time())
start = hub.start
else:
ctx, owned_store, start = build_context(settings, client)
app.state.ctx = ctx
app.state.hub = ctx.hub
start()
yield
finally:
# `ctx` is None when startup itself failed; the client and DB still need closing
if ctx is not None:
await ctx.hub.stop()
await client.aclose()
if owned_store is not None and store is None:
owned_store.close()
app = FastAPI(title="Homelab Dashboards", version=VERSION, lifespan=lifespan,
docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory=str(WEB / "static")), name="static")
for path, filename in PAGES.items():
app.add_api_route(path, _page_handler(filename), methods=["GET"], name=f"page:{path}")
app.include_router(common_router())
app.include_router(system_router())
app.include_router(llm_router())
app.include_router(ups_router())
@app.exception_handler(UpstreamError)
async def upstream_error_handler(request: Request, exc: UpstreamError) -> JSONResponse:
return JSONResponse({"ok": False, "detail": exc.message}, status_code=exc.status,
headers=NO_STORE)
@app.middleware("http")
async def no_store_for_api(request: Request, call_next):
"""Every /api response is `no-store`, success or failure.
A dashboard is a polling app: a cached snapshot in a proxy or the browser makes a
dead host look alive for as long as the TTL. Static assets keep their caching.
"""
response = await call_next(request)
if request.url.path.startswith("/api"):
response.headers["cache-control"] = "no-store, max-age=0"
return response
@app.exception_handler(StarletteHTTPException)
async def http_error_handler(request: Request,
exc: StarletteHTTPException) -> JSONResponse:
"""One JSON error shape for every failure, deliberate or not.
A dict detail is a structured answer (`{"configured": false, ...}`) and is spread
into the body so clients can branch on it. A status-specific 404 handler must NOT be
registered: Starlette resolves handlers by status code first, so it would also swallow
the deliberate `404 {"configured": false}` from an unconfigured source — the difference
between "not deployed here" and "deployed and broken" on the page. Unmatched routes
arrive here as Starlette's plain "Not Found" and get relabelled.
"""
body: dict[str, Any] = {"ok": False}
detail: Any = exc.detail # Starlette types this as str | dict | list | None
if isinstance(detail, dict):
body.update(detail)
elif exc.status_code == 404 and str(detail) in ("Not Found", "Not Found."):
body["detail"] = f"no such route: {request.url.path}"
else:
body["detail"] = detail
return JSONResponse(body, status_code=exc.status_code, headers=NO_STORE)
return app
def _page_handler(filename: str) -> Callable[..., Awaitable[FileResponse]]:
async def handler() -> FileResponse:
return FileResponse(WEB / "pages" / filename, media_type="text/html", headers=NO_STORE)
return handler
app = create_app()
+244
View File
@@ -0,0 +1,244 @@
"""Collectors: the fetch half of each poll. Parse + store live in the Job wrapper.
Every collector returns a `Result`; every failure is an exception the Job turns into a
red dot plus an event. Nothing here writes to any upstream — with one deliberate exception,
`run_ups_command`, which is the app's only mutating path and is gated in three places.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
from .config import Target
from .poller import Job, Result
from .rates import RateEstimator
from .sources import llm as llm_source
from .sources import nut
from .sources.host import HostCollector, parse_host_snapshot
from .store import Event, Sample
class UpstreamError(Exception):
"""An upstream answered badly or not at all; 502/504 in the API, red dot in the UI."""
def __init__(self, message: str, *, status: int = 502) -> None:
super().__init__(message)
self.message = message
self.status = status
# --------------------------------------------------------------------------- #
# System hosts
# --------------------------------------------------------------------------- #
async def fetch_host_snapshot(client: httpx.AsyncClient, target: Target,
host_collector: HostCollector) -> Result:
url = f"{target.url}/snapshot"
try:
response = await client.get(url, headers=target.headers)
except httpx.TimeoutException as exc:
raise UpstreamError(f"{target.name}: {url} timed out", status=504) from exc
except httpx.HTTPError as exc:
raise UpstreamError(f"{target.name}: {url} unreachable ({type(exc).__name__})") from exc
if response.status_code == 401:
raise UpstreamError(f"{target.name}: agent rejected the token (401) — check the "
f"userinfo token in HOST_TARGETS")
if response.status_code >= 400:
raise UpstreamError(f"{target.name}: {url} -> HTTP {response.status_code}")
try:
body = response.json()
except ValueError as exc:
raise UpstreamError(f"{target.name}: agent returned non-JSON "
f"({response.text[:80]!r})") from exc
if not body.get("ok", False):
raise UpstreamError(f"{target.name}: agent said not ok: {body.get('detail')}")
result = parse_host_snapshot(target.name, body, host_collector)
if body.get("errors"):
# The agent degraded a section (a /proc read failed) but still answered. Say so
# in the payload rather than presenting a partial snapshot as a complete one.
result.payload["degraded"] = body["errors"]
return result
# --------------------------------------------------------------------------- #
# LLM engines
# --------------------------------------------------------------------------- #
async def fetch_engine(client: httpx.AsyncClient, engine: str, base_url: str,
api_key: str, rates: RateEstimator, *,
with_slots: bool = False) -> Result:
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
url = f"{base_url}/metrics"
try:
response = await client.get(url, headers=headers)
except httpx.TimeoutException as exc:
raise UpstreamError(f"{engine}: {url} timed out", status=504) from exc
except httpx.HTTPError as exc:
raise UpstreamError(f"{engine}: {url} unreachable ({type(exc).__name__})") from exc
if response.status_code >= 400:
raise UpstreamError(f"{engine}: {url} -> HTTP {response.status_code} "
f"{response.reason_phrase}")
text = response.text
slots: list[dict[str, Any]] | None = None
if with_slots:
# /slots is gufo's; /v1/slots is the documented alias, so a server that only
# exposes the alias still gives us the per-session list.
for path in ("/slots", "/v1/slots"):
try:
slots_response = await client.get(f"{base_url}{path}", headers=headers)
except httpx.HTTPError:
continue
if slots_response.status_code == 200:
try:
data = slots_response.json()
except ValueError:
continue
slots = data if isinstance(data, list) else data.get("slots", [])
break
return llm_source.engine_result(engine, base_url, text, rates, slots=slots)
# --------------------------------------------------------------------------- #
# UPS / NUT
# --------------------------------------------------------------------------- #
@dataclass
class NutState:
"""Previous poll's per-UPS status, so transitions are events and not spam."""
status: dict[str, str] = field(default_factory=dict)
test_results: dict[str, str] = field(default_factory=dict)
async def read(self, client: httpx.AsyncClient | None, settings: Any) -> Result:
raise NotImplementedError
class NutMonitor:
"""Polls upsd once and returns every UPS. Also owns the transition bookkeeping."""
def __init__(self, settings: Any, simulator: Any | None = None) -> None:
self.settings = settings
self.simulator = simulator
self._status: dict[str, str] = {}
self._test_results: dict[str, str] = {}
self.simulated = simulator is not None
async def collect(self) -> Result:
now = time.time()
readings: list[nut.UpsReading] = []
if self.simulator is not None:
readings = await self.simulator.read()
else:
async with nut.NutClient(
self.settings.nut_host, self.settings.nut_port,
timeout=self.settings.nut_timeout,
username=self.settings.nut_user or None,
password=self.settings.nut_password or None,
) as client:
for entry in await client.list_ups():
ups = entry["name"]
reading = nut.UpsReading(name=ups, description=entry.get("description", ""),
ts=now)
reading.vars = await client.list_vars(ups)
try:
reading.commands = await client.list_cmds(ups)
except nut.NutError:
reading.commands = []
try:
reading.writable = await client.list_rw_vars(ups)
except nut.NutError:
reading.writable = []
readings.append(reading)
samples = []
events = []
payloads = []
for reading in readings:
card = nut.payload(reading, now=now)
card["commands"] = nut.available_commands(
reading.commands,
allow_actions=self.settings.actions_allowed,
read_only=self.settings.read_only,
# the simulator needs no credentials; refusing its commands would make the
# dev buttons dead while claiming a permissions problem that does not exist
has_credentials=bool(self.settings.nut_user and self.settings.nut_password) or self.simulated,
on_battery=reading.on_battery,
)
payloads.append(card)
samples.extend(
Sample("ups", reading.name, metric, value, reading.ts)
for metric, value in nut.history_samples(reading).items()
)
events.extend(self._transitions(reading, now))
payload = {
"ts": now,
"server": None if self.simulated else f"{self.settings.nut_host}:{self.settings.nut_port}",
"simulated": self.simulated,
"ups": payloads,
"actions_enabled": self.settings.actions_allowed,
}
return Result(payload=payload, samples=samples, events=events)
def _transitions(self, reading: nut.UpsReading, now: float) -> list[Event]:
"""Log a state change once, at the severity of the state we landed in."""
out: list[Event] = []
status = reading.get("status") or ""
previous = self._status.get(reading.name)
if previous is None:
self._status[reading.name] = status
return out
if status != previous:
severities = {s["severity"] for s in reading.states()}
severity = ("error" if "fail" in severities else "warn" if "warn" in severities
else "info")
out.append(Event(now, "ups", reading.name, "status", severity,
f"{reading.name}: {previous or '(no state)'} → {status or '(no state)'}",
{"from": previous, "to": status,
"states": reading.states(),
"charge_pct": reading.number("charge_pct"),
"runtime_seconds": reading.number("battery_runtime")}))
self._status[reading.name] = status
result = reading.get("test_result")
if result and self._test_results.get(reading.name) != result:
first_seen = reading.name not in self._test_results
self._test_results[reading.name] = result
if not first_seen: # the first sighting is not news, a change is
passed = result.strip().lower() in ("ok", "passed", "pass", "completed")
out.append(Event(now, "ups", reading.name, "test",
"info" if passed else "warn",
f"{reading.name}: self-test reported {result}"))
return out
# --------------------------------------------------------------------------- #
# Job factories (what app.py wires into the Hub)
# --------------------------------------------------------------------------- #
def host_jobs(targets: list[Target], client: httpx.AsyncClient, host_collector: HostCollector,
interval: float) -> list[Job]:
return [
Job(f"host:{target.name}", "system", target.name, interval,
lambda t=target: fetch_host_snapshot(client, t, host_collector))
for target in targets
]
def llm_jobs(settings: Any, client: httpx.AsyncClient, rates: RateEstimator) -> list[Job]:
jobs: list[Job] = []
if settings.gufo_url:
jobs.append(Job("llm:gufo", "llm", "gufo", settings.llm_interval,
lambda: fetch_engine(client, "gufo", settings.gufo_url,
settings.gufo_api_key, rates, with_slots=True)))
if settings.vllm_url:
jobs.append(Job("llm:vllm", "llm", "vLLM", settings.llm_interval,
lambda: fetch_engine(client, "vllm", settings.vllm_url,
settings.vllm_api_key, rates)))
return jobs
def nut_job(settings: Any, monitor: NutMonitor, client: httpx.AsyncClient) -> Job:
return Job("nut", "ups", "NUT server", settings.nut_interval, monitor.collect,
enabled=settings.nut_enabled)
+199
View File
@@ -0,0 +1,199 @@
"""Environment-driven configuration. Read once at import; restart to pick up changes.
There is no config file format and no CLI flags on purpose: deployment is a container with
env vars (see Containerfile + the deployment repo's quadlet env template). A `.env` file is
only a convenience for the dev loop — it seeds the environment and never overrides a real
variable. Every value has a safe default EXCEPT the sources, which default to *unset* — an
unset source means "this deployment does not run it", which must render as "not configured",
never as a red panel for a server the operator never installed.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def load_env_file() -> Path | None:
"""Seed the environment from `DASH_ENV_FILE` (default: `.env` at the repo root).
`override=False`, so a variable the container, systemd unit, or shell already set always
beats the file. `.env.example` is documentation and is never loaded, and a missing file is
the normal case in production — not a warning.
"""
raw = os.environ.get("DASH_ENV_FILE", "").strip()
path = Path(raw).expanduser() if raw else ROOT / ".env"
if not path.is_file() or path.name == ".env.example":
return None
from dotenv import load_dotenv # noqa: PLC0415 - only needed when a file exists
load_dotenv(path, override=False)
return path
# This runs here, at the top of the module, because `Settings` below reads the environment
# in its field defaults — which are evaluated once, when the class body executes. Loading the
# file from `load()` instead would seed the environment after those defaults had already been
# taken, and the file would silently do nothing.
load_env_file()
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _num(name: str, default: float) -> float:
raw = _env(name)
try:
return float(raw) if raw else default
except ValueError:
return default
def _int(name: str, default: int) -> int:
return int(_num(name, float(default)))
def _flag(name: str, default: bool = False) -> bool:
raw = _env(name).lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
def _base_url(name: str) -> str:
"""Normalised base URL, or `""` when unset/blank. No default host, ever."""
return _env(name).rstrip("/")
# A target is `name=url` (comma or whitespace separated). The URL may carry a bearer
# token as userinfo (`http://SECRET@host:9120`) so per-host agent tokens need no extra
# variable; the token never leaves the backend, it is stripped from every /api payload.
_TARGET_SPLIT = re.compile(r"[,\s]+")
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
@dataclass(frozen=True)
class Target:
"""One scrape endpoint. `name` is stable UI text and the history DB's host key."""
name: str
url: str
headers: dict[str, str] = field(default_factory=dict)
@property
def display_url(self) -> str:
"""URL with any userinfo removed — safe to show in the browser."""
return re.sub(r"^(https?://)[^/@]+@", r"\1", self.url)
def parse_targets(raw: str) -> tuple[list[Target], list[str]]:
"""Parse `name=url,name=url` into targets, plus a list of problems found.
Bad entries are reported, not raised: one typo in a list of six hosts must not
take the whole dashboard down. The problems surface in /api/config so they are
diagnosable without reading container logs.
"""
targets: list[Target] = []
problems: list[str] = []
for chunk in _TARGET_SPLIT.split(raw.strip()):
if not chunk:
continue
name, sep, url = chunk.partition("=")
name, url = name.strip(), url.strip().rstrip("/")
if not sep or not url:
problems.append(f"{chunk!r} is not name=url")
continue
if not _NAME_RE.match(name):
problems.append(f"{name!r} is not a valid target name")
continue
if not url.startswith(("http://", "https://")):
problems.append(f"{name}: url must start with http:// or https://")
continue
headers: dict[str, str] = {}
m = re.match(r"^(https?://)([^/@]+)@(.*)$", url)
if m:
scheme, token, rest = m.groups()
url = f"{scheme}{rest}"
headers["Authorization"] = f"Bearer {token}"
targets.append(Target(name=name, url=url, headers=headers))
return targets, problems
@dataclass(frozen=True)
class Settings:
# --- server -------------------------------------------------------------------
host: str = _env("DASH_HOST", "127.0.0.1")
port: int = _int("DASH_PORT", 8090)
db_path: str = _env("DASH_DB", str(ROOT / "data" / "dashboards.sqlite3"))
read_only: bool = _flag("DASH_READ_ONLY") # kills every mutating endpoint
# --- system dashboard ---------------------------------------------------------
host_targets: tuple[Target, ...] = ()
host_target_problems: tuple[str, ...] = ()
host_interval: float = _num("HOST_INTERVAL_SECONDS", 10)
host_timeout: float = _num("HOST_TIMEOUT_SECONDS", 4)
# --- llm dashboard ------------------------------------------------------------
gufo_url: str = _base_url("GUFO_BASE_URL")
gufo_api_key: str = _env("GUFO_API_KEY")
vllm_url: str = _base_url("VLLM_BASE_URL")
vllm_api_key: str = _env("VLLM_API_KEY")
llm_interval: float = _num("LLM_INTERVAL_SECONDS", 2)
llm_timeout: float = _num("LLM_TIMEOUT_SECONDS", 3)
rate_window: float = _num("RATE_WINDOW_SECONDS", 10)
# --- ups dashboard ------------------------------------------------------------
nut_host: str = _env("NUT_HOST").removeprefix("nut://")
nut_port: int = _int("NUT_PORT", 3493)
nut_user: str = _env("NUT_USER")
nut_password: str = _env("NUT_PASSWORD")
nut_interval: float = _num("NUT_INTERVAL_SECONDS", 5)
nut_timeout: float = _num("NUT_TIMEOUT_SECONDS", 4)
# Instant commands (battery tests, beeper, calibration) are the only write path in
# the app. Off unless BOTH the env flag is set and DASH_READ_ONLY is not.
nut_allow_actions: bool = _flag("NUT_ALLOW_ACTIONS")
# --- history ------------------------------------------------------------------
raw_keep_hours: float = _num("RETAIN_RAW_HOURS", 48)
minute_keep_days: float = _num("RETAIN_MINUTE_DAYS", 30)
hour_keep_days: float = _num("RETAIN_HOUR_DAYS", 400)
# --- browser polling defaults -------------------------------------------------
poll_ms_system: int = _int("POLL_MS_SYSTEM", 10_000)
poll_ms_llm: int = _int("POLL_MS_LLM", 2_000)
poll_ms_ups: int = _int("POLL_MS_UPS", 5_000)
@property
def nut_enabled(self) -> bool:
return bool(self.nut_host)
@property
def actions_allowed(self) -> bool:
"""Coarse switch. Whether a *specific* command can run also depends on the user
having `actions` in upsd.users, which is checked per command (and reported to the
UI as a reason) — requiring credentials here would make the label wrong for the
simulator and would hide the actual fix from the operator."""
return self.nut_allow_actions and not self.read_only
@property
def any_source_enabled(self) -> bool:
return bool(self.host_targets) or bool(self.gufo_url or self.vllm_url) or self.nut_enabled
def load() -> Settings:
"""Settings from the environment as it stands now.
Every scalar was captured when this module was imported (see `load_env_file`); only the
target list is re-parsed per call, which is what makes a target fixable without a reload
in tests and tooling.
"""
targets, problems = parse_targets(_env("HOST_TARGETS"))
return Settings(host_targets=tuple(targets), host_target_problems=tuple(problems))
SETTINGS = load()
+24
View File
@@ -0,0 +1,24 @@
"""Optional debugpy attach, gated by the DEBUGPY env var.
Default (unset / DEBUGPY=0): this module does nothing and imports nothing heavy.
DEBUGPY=1: listens on DEBUGPY_PORT (default 5678) without blocking startup.
"""
from __future__ import annotations
import os
def maybe_listen() -> bool:
"""Start a debugpy listener when DEBUGPY is truthy. Returns True if it did."""
if os.environ.get("DEBUGPY", "").lower() not in ("1", "true", "yes", "on"):
return False
port = int(os.environ.get("DEBUGPY_PORT", "5678"))
try:
import debugpy # noqa: PLC0415 - deliberately imported only when requested
except ImportError:
print(f"[debug] DEBUGPY is set but debugpy is not installed (uv sync --dev); port {port} unused", flush=True)
return False
debugpy.listen(("0.0.0.0", port))
print(f"[debug] debugpy listening on 0.0.0.0:{port}", flush=True)
return True
+230
View File
@@ -0,0 +1,230 @@
"""Polling framework: one asyncio loop per source, independent health for each.
Rules this enforces, because violating them is what makes dashboards untrustworthy:
* **Isolation.** A source that throws, hangs, or 500s must never blank, block, or delay
another one. Each job has its own task, its own timeout (the collector's problem) and
its own health record.
* **Last good value wins.** A failed poll keeps the previous payload and marks it stale
(`stale 12s` in the UI) instead of showing zeros or `n/a` everywhere.
* **Transitions are events, spam is not.** ok→fail and fail→ok write to the event log;
500 consecutive failures write one line.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from .store import Event, Sample, SampleStore
logger = logging.getLogger("dashboards.poller")
@dataclass
class Result:
"""What one successful collection produced."""
payload: dict[str, Any]
samples: list[Sample] = field(default_factory=list)
events: list[Event] = field(default_factory=list)
@dataclass
class SourceState:
key: str # "host:driveripper", "llm:gufo", "nut"
dashboard: str # "system" | "llm" | "ups"
label: str
configured: bool = True
ok: bool = False
error: str | None = None
attempts: int = 0
successes: int = 0
failures: int = 0
consecutive_failures: int = 0
last_attempt: float | None = None
last_ok: float | None = None
latency_ms: float | None = None
samples_written: int = 0
payload: dict[str, Any] | None = None
def as_json(self, now: float | None = None) -> dict[str, Any]:
"""Health block for the browser. `age_seconds` only counts successes, so a dead
source reads as "stale 4m" rather than pretending to be current."""
now = time.time() if now is None else now
return {
"key": self.key,
"dashboard": self.dashboard,
"label": self.label,
"configured": self.configured,
"ok": self.ok,
"error": self.error,
"attempts": self.attempts,
"successes": self.successes,
"failures": self.failures,
"consecutive_failures": self.consecutive_failures,
"last_attempt": self.last_attempt,
"last_ok": self.last_ok,
"age_seconds": (now - self.last_ok) if self.last_ok else None,
"latency_ms": self.latency_ms,
"samples_written": self.samples_written,
}
class Job:
"""A named poll loop. `fn` does the work; everything else lives here so every
dashboard gets identical health, staleness, and event semantics for free."""
def __init__(self, key: str, dashboard: str, label: str, interval: float,
fn: Callable[[], Awaitable[Result]], *, enabled: bool = True,
grace_failures: int = 1) -> None:
self.key = key
self.dashboard = dashboard
self.label = label
self.interval = max(0.5, interval)
self.fn = fn
self.enabled = enabled
self.grace_failures = grace_failures
self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled)
self._down_logged = False
self._stop = asyncio.Event()
self._task: asyncio.Task[None] | None = None
self._inflight = False
async def run_once(self, store: SampleStore) -> bool:
"""One poll, with the health bookkeeping. Returns True on success."""
if self._inflight: # a slow poll must never overlap itself
return False
self._inflight = True
now = time.time()
self.state.attempts += 1
self.state.last_attempt = now
was_ok = self.state.ok
try:
started = time.perf_counter()
result = await self.fn()
self.state.latency_ms = round((time.perf_counter() - started) * 1000.0, 1)
except Exception as exc: # noqa: BLE001 - a collector must never kill the loop
self.state.ok = False
self.state.failures += 1
self.state.consecutive_failures += 1
self.state.error = f"{type(exc).__name__}: {exc}"
# One event per outage, not per failed poll. A source that has never answered
# gets a warn rather than silence — otherwise a host misconfigured from the first
# boot leaves no trace in the log at all.
if (self.state.consecutive_failures >= self.grace_failures and not self._down_logged):
self._down_logged = True
store.log_event(Event(now, self.dashboard, self.label, "unreachable",
"error" if self.state.successes else "warn",
self.state.error
+ ("" if self.state.successes else " (never answered)")))
return False
finally:
self._inflight = False
self.state.ok = True
self.state.successes += 1
self.state.payload = result.payload
# Recovery is news only if we ever said it was down: on a fresh start every source
# would otherwise log "answering again", which reads like an outage happened.
if self._down_logged or (not was_ok and self.state.failures):
count = self.state.consecutive_failures
store.log_event(Event(now, self.dashboard, self.label, "reachable", "info",
f"{self.label} is answering again"
+ (f" after {count} failure{'s' if count != 1 else ''}"
if count else "")))
self._down_logged = False
self.state.consecutive_failures = 0
self.state.last_ok = now
self.state.error = None
self.state.samples_written += store.add_many(result.samples)
for event in result.events:
store.log_event(event)
return True
async def _loop(self, store: SampleStore) -> None:
while not self._stop.is_set():
started = time.perf_counter()
await self.run_once(store)
remaining = self.interval - (time.perf_counter() - started)
try:
await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining))
except TimeoutError:
continue
def start(self, store: SampleStore) -> None:
if self._task is None and self.enabled:
self._task = asyncio.create_task(self._loop(store), name=f"poll:{self.key}")
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
self._task.cancel()
# the cancellation is the point; the task's own outcome is not our problem here.
# CancelledError is a BaseException in 3.8+, so it has to be named explicitly.
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._task
self._task = None
class Hub:
"""Owns the store and every poll loop; the only thing the API layer talks to."""
def __init__(self, store: SampleStore) -> None:
self.store = store
self.jobs: dict[str, Job] = {}
self.started_at = time.time()
self._maintenance: asyncio.Task[None] | None = None
def add(self, job: Job) -> Job:
self.jobs[job.key] = job
return job
def start(self) -> None:
for job in self.jobs.values():
job.start(self.store)
if self._maintenance is None:
self._maintenance = asyncio.create_task(self._maintain_loop(), name="maintain")
async def stop(self) -> None:
if self._maintenance is not None:
self._maintenance.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._maintenance
self._maintenance = None
for job in self.jobs.values():
await job.stop()
async def _maintain_loop(self, interval: float = 60.0) -> None:
"""Rollup + prune. Runs in a thread: SQLite writes would otherwise stall the loop."""
# imported here because only this task needs the thread bridge
from anyio.to_thread import run_sync # noqa: PLC0415
while True:
try:
await run_sync(self.store.maintain)
except Exception:
# Logged, never swallowed: a broken rollup means every chart past the raw
# window is quietly empty, and "it retried" is not a record of that.
logger.exception("store maintenance failed; long-window charts may be stale")
await asyncio.sleep(interval)
def states(self, dashboard: str | None = None) -> list[dict[str, Any]]:
now = time.time()
return [
job.state.as_json(now)
for job in self.jobs.values()
if dashboard is None or job.dashboard == dashboard
]
def payloads(self, dashboard: str | None = None) -> dict[str, dict[str, Any]]:
return {
job.key: job.state.payload
for job in self.jobs.values()
if (dashboard is None or job.dashboard == dashboard) and job.state.payload is not None
}
+177
View File
@@ -0,0 +1,177 @@
"""Prometheus text-exposition parser. In-house, zero dependencies.
Only used for the upstreams we do not control: llama.cpp/gufo (`llamacpp:*`) and vLLM
(`vllm:*`). Our own host agent serves JSON, so nothing else here needs this parser and we
never pull in `prometheus_client`.
Ported from llm_monitor, where it survived a year of both engines renaming series under
it. Keep the aggregation rules (see `Prom.scalar`): they are what stops a summed ratio
from exceeding 1.0 and inventing a percentile.
"""
from __future__ import annotations
import math
import re
from collections.abc import Sequence
from typing import Any
_NAME_RE = r"[a-zA-Z_:][a-zA-Z0-9_:]*"
# name{label="v",...} value [timestamp]
# - the label block is greedy so a '}' inside a quoted value cannot truncate the match;
# the anchored value/end-of-line does that instead.
# - whitespace before '{' and a trailing '# ...' comment are not part of the official
# format, but tolerating them means a quirky exporter shows a number instead of 'n/a'.
_LINE_RE = re.compile(
rf"^(?P<name>{_NAME_RE})(?:[ \t]*\{{(?P<labels>.*)\}})?[ \t]+"
r"(?P<value>[+-]?(\d+\.?\d*(e[+-]?\d+)?|inf|nan))"
r"(?:[ \t]+(?P<ts>-?\d+))?[ \t]*(?:#.*)?$",
re.IGNORECASE,
)
_LABEL_RE = re.compile(r'([a-zA-Z_][a-zA-Z0-9_.:]*)="((?:[^"\\]|\\.)*)"')
def _unquote(value: str) -> str:
return re.sub(r"\\(.)", r"\1", value)
class Prom:
"""Parsed exposition: name -> [(labels, value), ...]."""
def __init__(self, text: str) -> None:
self.series: dict[str, list[tuple[dict[str, str], float]]] = {}
self.parse_errors = 0
for raw in text.splitlines():
line = raw.strip()
if not line or line[0] == "#":
continue
m = _LINE_RE.match(line)
if not m:
self.parse_errors += 1
continue
try:
value = float(m.group("value"))
except ValueError: # pragma: no cover - regex keeps this unreachable
self.parse_errors += 1
continue
labels = {_unquote(k): _unquote(v) for k, v in _LABEL_RE.findall(m.group("labels") or "")}
self.series.setdefault(m.group("name"), []).append((labels, value))
def resolve(self, names: Sequence[str]) -> str | None:
"""First name from `names` that is actually present."""
return next((n for n in names if n in self.series), None)
def resolve_hist(self, names: Sequence[str]) -> str | None:
"""Like resolve() for histograms: the bare name never appears, only *_bucket/_sum/_count."""
for n in names:
if any(f"{n}{suffix}" in self.series for suffix in ("_bucket", "_sum", "_count")):
return n
return None
def values(self, names: Sequence[str], label_match: dict[str, str] | None = None) -> list[float]:
name = self.resolve(names)
if name is None:
return []
if not label_match:
return [v for _labels, v in self.series[name]]
return [
v for labels, v in self.series[name]
if all(labels.get(k) == x for k, x in label_match.items())
]
def scalar(self, names: Sequence[str], agg: str = "sum",
label_match: dict[str, str] | None = None) -> float | None:
"""Aggregate every matching series, or None when absent.
agg="sum" counters and per-engine gauges — summing label sets is the fleet total.
agg="max" usage ratios — report the most pressured engine, never a sum over 1.0.
agg="avg" rate gauges — an average across label sets.
"""
found = self.values(names, label_match)
finite = [v for v in found if math.isfinite(v)]
if not finite:
return None
if agg == "max":
return max(finite)
if agg == "avg":
return sum(finite) / len(finite)
return sum(finite)
def by_label(self, names: Sequence[str], label: str) -> dict[str, float]:
name = self.resolve(names)
if name is None:
return {}
out: dict[str, float] = {}
for labels, value in self.series[name]:
if not math.isfinite(value):
continue
key = labels.get(label, "")
out[key] = out.get(key, 0.0) + value
return out
def histogram(self, names: Sequence[str]) -> dict[str, Any] | None:
"""Mean (+ p95 when interpolable) of a `<name>_sum`/`_count`/`_bucket`."""
base = self.resolve_hist(names)
if base is None:
return None
total = self.scalar([f"{base}_sum"])
count = self.scalar([f"{base}_count"])
buckets = self._buckets(base)
out: dict[str, Any] = {
"count": count,
"sum": total,
"mean": (total / count) if (total is not None and count) else None,
"p95": None,
"source": base,
}
# Interpolate only for a single label set: mixing per-DP-rank histograms into one
# cumulative curve invents a bogus p95.
if buckets and count and self._bucket_label_groups(base) <= 1:
out["p95"] = _quantile(buckets, count, 0.95)
return out
def _bucket_label_groups(self, base: str) -> int:
groups = set()
for labels, _v in self.series.get(f"{base}_bucket", []):
groups.add(tuple(sorted((k, v) for k, v in labels.items() if k != "le")))
return len(groups)
def _buckets(self, base: str) -> list[tuple[float, float]]:
out: list[tuple[float, float]] = []
for labels, value in self.series.get(f"{base}_bucket", []):
le = labels.get("le")
if le is None or not math.isfinite(value):
continue
bound = math.inf if le.lower() in ("+inf", "inf") else float(le)
out.append((bound, value))
out.sort(key=lambda item: item[0])
return out
def _quantile(buckets: list[tuple[float, float]], count: float, q: float) -> float | None:
"""Linear interpolation inside the bucket containing the `q` quantile."""
target = q * count
prev_bound, prev_cum = 0.0, 0.0
for bound, cum in buckets:
if cum >= target:
if math.isinf(bound):
return prev_bound if math.isfinite(prev_bound) else None
span = cum - prev_cum
if span <= 0:
return bound
return prev_bound + (bound - prev_bound) * ((target - prev_cum) / span)
prev_bound, prev_cum = bound, cum
return None
def field(value: float | None, source: str | None, derived: bool = False,
window: float | None = None) -> dict[str, Any]:
"""Uniform metric cell: the UI renders `n/a` whenever value is None.
`window` is the Δt a rate was measured over, so the UI can say `Δ10.0s` instead of
implying a scraped gauge it never got.
"""
cell: dict[str, Any] = {"value": value, "source": source, "derived": derived}
if window is not None:
cell["window"] = window
return cell
+64
View File
@@ -0,0 +1,64 @@
"""Δcounter / Δt over a rolling window — the one piece of mutable state in the app.
Both upstreams export cumulative counters (tokens generated, jiffies spent, bytes read)
and the dashboards need per-second rates. A bare 1s delta jitters between 0 and spikes
because work arrives in bursts, so the rate is measured over a window (default 10s) and
the window that was actually used travels with the value — the UI prints `Δ10.0s` rather
than implying a scraped gauge it never got.
Keyed by the *resolved* upstream series name (e.g. `llamacpp:tokens_predicted_total`, or
`driveripper:cpu.total`), so a renamed counter starts a fresh window instead of diffing
two different series into a fabricated rate, and two hosts can never read each other's
samples.
"""
from __future__ import annotations
import math
import time
from collections import deque
class RateEstimator:
def __init__(self, span: float = 10.0, max_samples: int = 512) -> None:
self.span = span
self.max_samples = max_samples
self._samples: dict[str, deque[tuple[float, float]]] = {}
def rate(self, key: str, value: float | None, now: float | None = None) -> tuple[float | None, float | None]:
"""(per-second, window actually used). (None, window|None) when not computable.
Never returns a negative rate: a counter going backwards means a restart (or a
renamed series), so the window is dropped and one scrape answers `n/a`.
"""
if value is None or not math.isfinite(value):
return None, None
now = time.time() if now is None else now
samples = self._samples.setdefault(key, deque())
if samples and value < samples[-1][1]:
samples.clear()
samples.append((now, value))
while len(samples) > self.max_samples:
samples.popleft()
if len(samples) < 2:
return None, None
inside = [point for point in samples if now - point[0] <= self.span]
base = inside[0] if len(inside) > 1 else samples[-2]
dt, dv = now - base[0], value - base[1]
cutoff = now - 2 * self.span
while samples and samples[0][0] < cutoff and len(samples) > 2:
samples.popleft()
if dt <= 0 or dv < 0:
return None, dt if dt > 0 else None
return dv / dt, dt
def forget(self, prefix: str) -> int:
"""Drop every window whose key starts with `prefix` (a target that went away)."""
keys = [k for k in self._samples if k.startswith(prefix)]
for key in keys:
del self._samples[key]
return len(keys)
def keys(self) -> list[str]:
return list(self._samples)
+7
View File
@@ -0,0 +1,7 @@
"""Data sources. Each module turns one upstream's raw payload into our cell shape.
host -> our own stdlib agent (JSON)
llm -> gufo / vLLM Prometheus expositions (parsed by our own Prom class)
nut -> the upsd wire protocol (reads + instant commands)
nut_sim-> a labelled simulator for development, never a substitute for real hardware
"""
+416
View File
@@ -0,0 +1,416 @@
"""Host source: reads `agent/host_agent.py` JSON and turns counters into rates.
Division of labour, and why:
* the agent reads /proc and /sys and reports **cumulative counters** (it cannot know how
long the interval was, and it must not have to remember who polled it last);
* this module owns the **window**: every per-second number is Δcounter/Δt over
`RateEstimator`, and carries the window that produced it, so the UI can print `Δ10.0s`.
* absent values stay None. A host with no GPU has no gpu series; it does not have a GPU at
0% busy (that would say "idle", which is a different claim from "there is nothing here").
Metric names in the history DB use `@stream` for per-device series:
`cpu.usage_pct@core7`, `disk.used_pct@/srv`, `gpu.busy_pct@0`, `net.rx_bytes_per_s@eth0`.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from ..poller import Result
from ..rates import RateEstimator
from ..store import Sample
# /proc/stat jiffies we treat as "not doing useful work".
CPU_IDLE_MODES = ("idle", "iowait")
@dataclass
class HostTargetState:
"""Per-target memory across polls: only what a *comparison* needs."""
boot_ts: float | None = None
hostname: str | None = None
@dataclass
class HostCollector:
"""Holds the rate windows and the reboot detector for every host."""
rates: RateEstimator
targets: dict[str, HostTargetState] = field(default_factory=dict)
def target_state(self, name: str) -> HostTargetState:
return self.targets.setdefault(name, HostTargetState())
def _rate(collector: HostCollector, host: str, key: str, value: float | None,
now: float) -> tuple[float | None, float | None]:
return collector.rates.rate(f"{host}:{key}", value, now)
def _ratio_percent(numerator: float | None, denominator: float | None) -> float | None:
if numerator is None or denominator is None or denominator <= 0:
return None
return max(0.0, min(100.0, numerator / denominator * 100.0))
def parse_host_snapshot(name: str, snapshot: dict[str, Any], collector: HostCollector,
*, now: float | None = None) -> Result:
"""One agent snapshot -> (UI payload, history samples, transition events)."""
now = time.time() if now is None else now
samples: list[Sample] = []
events: list[Any] = []
ts = float(snapshot.get("ts") or now)
info = snapshot.get("host") or {}
state = collector.target_state(name)
boot_ts = info.get("boot_ts")
if boot_ts is not None:
if state.boot_ts is not None and abs(boot_ts - state.boot_ts) > 1.0:
events.append(_event(now, name, "reboot", "warn",
f"{name} rebooted (agent boot time moved from "
f"{state.boot_ts:.0f} to {boot_ts:.0f})"))
state.boot_ts = boot_ts
if info.get("hostname"):
state.hostname = str(info["hostname"])
# ---------------------------------------------------------------- CPU ---- #
cpu_raw = snapshot.get("cpu") or {}
modes = cpu_raw.get("modes") or {}
total_rate, window = _rate(collector, name, "cpu.total", modes.get("total"), now)
# Every counter is sampled on every poll, including the first. Gating the idle modes on
# `total_rate is not None` (as this used to) meant idle was never seeded on poll 1, so
# usage_pct stayed n/a until poll 3 — a dashboard that boots two intervals late, and a
# fleet average that counts a live host as missing for its first minute.
idle_rate, idle_ok = 0.0, True
for mode in CPU_IDLE_MODES:
value, _ = _rate(collector, name, f"cpu.{mode}", modes.get(mode), now)
if value is None:
idle_ok = False
else:
idle_rate += value
busy_rate = max(0.0, total_rate - idle_rate) if (idle_ok and total_rate is not None) else None
usage_pct = (busy_rate / total_rate * 100.0) if (busy_rate is not None and total_rate) else None
mode_pct: dict[str, float | None] = {}
for mode in ("user", "system", "iowait", "irq", "softirq", "steal", "nice"):
value, _ = _rate(collector, name, f"cpu.{mode}", modes.get(mode), now)
mode_pct[mode] = _ratio_percent(value, total_rate) if total_rate else None
core_usage: list[dict[str, Any]] = []
for core in cpu_raw.get("cores") or []:
core_modes = core.get("modes") or {}
core_total, _ = _rate(collector, name, f"cpu.core{core['core']}.total",
core_modes.get("total"), now)
core_idle = 0.0
core_ok = True
for mode in CPU_IDLE_MODES:
value, _ = _rate(collector, name, f"cpu.core{core['core']}.{mode}",
core_modes.get(mode), now)
if value is None:
core_ok = False
else:
core_idle += value
core_busy = (core_total - core_idle) if (core_ok and core_total is not None) else None
value = _ratio_percent(core_busy, core_total) if core_total else None
core_usage.append({"core": core["core"], "usage_pct": value})
if value is not None:
samples.append(Sample("host", name, f"cpu.usage_pct@core{core['core']}", value, ts))
if usage_pct is not None:
samples.append(Sample("host", name, "cpu.usage_pct", usage_pct, ts))
for mode, value in mode_pct.items():
if value is not None:
samples.append(Sample("host", name, f"cpu.{mode}_pct", value, ts))
ctxt_rate, _ = _rate(collector, name, "cpu.ctxt", cpu_raw.get("ctxt_total"), now)
forks_rate, _ = _rate(collector, name, "cpu.processes", cpu_raw.get("processes_total"), now)
# -------------------------------------------------------------- Memory --- #
mem = snapshot.get("memory") or {}
mem_total = mem.get("total_bytes")
mem_used = mem.get("used_bytes")
mem_used_pct = _ratio_percent(mem_used, mem_total)
swap_total = mem.get("swap_total_bytes") or 0.0
swap_used = mem.get("swap_used_bytes") or 0.0
swap_used_pct = (swap_used / swap_total * 100.0) if swap_total else None
for key in ("total_bytes", "available_bytes", "used_bytes", "cached_bytes", "buffers_bytes",
"swap_total_bytes", "swap_used_bytes"):
if mem.get(key) is not None:
samples.append(Sample("host", name, f"mem.{key}", float(mem[key]), ts))
if mem_used_pct is not None:
samples.append(Sample("host", name, "mem.used_pct", mem_used_pct, ts))
if swap_used_pct is not None:
samples.append(Sample("host", name, "mem.swap_used_pct", swap_used_pct, ts))
# ----------------------------------------------------------- Filesystems - #
filesystems = []
for fs in snapshot.get("filesystems") or []:
used_pct = fs.get("used_pct")
filesystems.append(fs)
if used_pct is not None:
samples.append(Sample("host", name, f"disk.used_pct@{fs['mountpoint']}",
float(used_pct), ts))
if fs.get("used_bytes") is not None:
samples.append(Sample("host", name, f"disk.used_bytes@{fs['mountpoint']}",
float(fs["used_bytes"]), ts))
scored = [fs for fs in filesystems if fs.get("used_pct") is not None]
worst = max(scored, key=lambda fs: fs["used_pct"]) if scored else None
# -------------------------------------------------------------- Disk IO -- #
io_devices = []
read_total = write_total = reads_total = writes_total = 0.0
any_io = False
util_max: float | None = None
for dev in (snapshot.get("disk_io") or {}).get("devices") or []:
dev_name = dev.get("name") or "?"
read_rate, _ = _rate(collector, name, f"disk.{dev_name}.read_bytes",
dev.get("read_bytes_total"), now)
write_rate, _ = _rate(collector, name, f"disk.{dev_name}.write_bytes",
dev.get("write_bytes_total"), now)
r_ops, _ = _rate(collector, name, f"disk.{dev_name}.reads", dev.get("reads_total"), now)
w_ops, _ = _rate(collector, name, f"disk.{dev_name}.writes", dev.get("writes_total"), now)
io_ms_rate, _ = _rate(collector, name, f"disk.{dev_name}.io_time_ms",
dev.get("io_time_ms_total"), now)
# io_time is milliseconds spent with at least one request outstanding, so
# ms-per-second / 10 == percent of the window the device was busy.
util = None if io_ms_rate is None else max(0.0, min(100.0, io_ms_rate / 10.0))
row = {"name": dev_name, "model": dev.get("model"), "rotational": dev.get("rotational"),
"size_bytes": dev.get("size_bytes"),
"read_bytes_per_s": read_rate, "write_bytes_per_s": write_rate,
"reads_per_s": r_ops, "writes_per_s": w_ops, "util_pct": util,
"in_flight": dev.get("in_flight")}
io_devices.append(row)
if read_rate is not None:
read_total += read_rate
any_io = True
if write_rate is not None:
write_total += write_rate
any_io = True
if r_ops is not None:
reads_total += r_ops
if w_ops is not None:
writes_total += w_ops
if util is not None:
util_max = util if util_max is None else max(util_max, util)
# ------------------------------------------------------------- Network --- #
net_rows = []
rx_total = tx_total = 0.0
any_net = False
for iface in (snapshot.get("network") or {}).get("interfaces") or []:
iname = iface.get("name") or "?"
rx, _ = _rate(collector, name, f"net.{iname}.rx", iface.get("rx_bytes_total"), now)
tx, _ = _rate(collector, name, f"net.{iname}.tx", iface.get("tx_bytes_total"), now)
net_rows.append({"name": iname, "rx_bytes_per_s": rx, "tx_bytes_per_s": tx,
"speed_mbps": iface.get("speed_mbps"),
"rx_errors_total": iface.get("rx_errors_total"),
"tx_errors_total": iface.get("tx_errors_total"),
"rx_dropped_total": iface.get("rx_dropped_total"),
"tx_dropped_total": iface.get("tx_dropped_total")})
if rx is not None:
rx_total += rx
any_net = True
samples.append(Sample("host", name, f"net.rx_bytes_per_s@{iname}", rx, ts))
if tx is not None:
tx_total += tx
samples.append(Sample("host", name, f"net.tx_bytes_per_s@{iname}", tx, ts))
# ---------------------------------------------------------------- GPU ---- #
gpus = []
for gpu in snapshot.get("gpus") or []:
index = gpu.get("index", len(gpus))
stream = f"@{index}"
vram_used = gpu.get("vram_used_bytes")
vram_total = gpu.get("vram_total_bytes")
gtt_used = gpu.get("gtt_used_bytes")
gtt_total = gpu.get("gtt_total_bytes")
temps = gpu.get("temps") or {}
temp_c = max(temps.values()) if temps else None
pools_used = None if vram_used is None else vram_used + (gtt_used or 0.0)
pools_total = None if vram_total is None else vram_total + (gtt_total or 0.0)
row = {
"index": index,
"card": gpu.get("card"),
"model": gpu.get("model") or gpu.get("card") or f"GPU {index}",
# the short name is what fits a legend; the lspci string belongs in the tooltip
"model_full": gpu.get("model_full") or gpu.get("model"),
"vendor": gpu.get("vendor"),
"driver": gpu.get("driver"),
"pci_address": gpu.get("pci_address"),
"busy_pct": gpu.get("busy_pct"),
"vram_used_bytes": vram_used,
"vram_total_bytes": vram_total,
"vram_used_pct": _ratio_percent(vram_used, vram_total),
"gtt_used_bytes": gtt_used,
"gtt_total_bytes": gtt_total,
# an APU's model lives in GTT, so "VRAM 3%" and "memory 78%" can both be true;
# this is the honest whole-addressable-pool view, kept beside the two pools
"mem_used_bytes": pools_used,
"mem_total_bytes": pools_total,
"mem_used_pct": _ratio_percent(pools_used, pools_total),
"power_watts": gpu.get("power_watts"),
"temps": temps,
"temp_c": temp_c,
"clocks": gpu.get("clocks") or {},
}
gpus.append(row)
for metric, value in (("busy_pct", row["busy_pct"]), ("vram_used_bytes", vram_used),
("vram_used_pct", row["vram_used_pct"]),
("mem_used_pct", row["mem_used_pct"]),
("power_watts", row["power_watts"]), ("temp_c", temp_c)):
if value is not None:
samples.append(Sample("host", name, f"gpu.{metric}{stream}", float(value), ts))
# -------------------------------------------------------- Temperatures --- #
sensors = snapshot.get("temperatures") or []
cpu_temp = snapshot.get("cpu_temperature_celsius")
values = [s["celsius"] for s in sensors if s.get("celsius") is not None]
max_temp = max(values) if values else None
nvme_values = [s["celsius"] for s in sensors
if s.get("chip") == "nvme" and s.get("celsius") is not None]
nvme_max = max(nvme_values) if nvme_values else None
if cpu_temp is not None:
samples.append(Sample("host", name, "temp.cpu_c", float(cpu_temp), ts))
if max_temp is not None:
samples.append(Sample("host", name, "temp.max_c", float(max_temp), ts))
if nvme_max is not None:
samples.append(Sample("host", name, "temp.nvme_max_c", float(nvme_max), ts))
# ------------------------------------------------------------ Processes -- #
processes = snapshot.get("processes") or {}
# ------------------------------------------------------------ Payload ---- #
for metric, value in (("cpu.load1", cpu_raw.get("load1")),
("cpu.load5", cpu_raw.get("load5")),
("cpu.load15", cpu_raw.get("load15")),
("cpu.freq_mhz", cpu_raw.get("freq_mhz")),
("cpu.context_switches_per_s", ctxt_rate),
("host.uptime_seconds", info.get("uptime_seconds")),
("host.up", 1.0),
("disk.read_bytes_per_s", read_total if any_io else None),
("disk.write_bytes_per_s", write_total if any_io else None),
("disk.reads_per_s", reads_total if any_io else None),
("disk.writes_per_s", writes_total if any_io else None),
("disk.util_pct", util_max),
("net.rx_bytes_per_s", rx_total if any_net else None),
("net.tx_bytes_per_s", tx_total if any_net else None)):
if value is not None:
samples.append(Sample("host", name, metric, float(value), ts))
if processes.get("total") is not None:
samples.append(Sample("host", name, "host.processes", float(processes["total"]), ts))
if worst is not None:
samples.append(Sample("host", name, "disk.used_pct", float(worst["used_pct"]), ts))
payload = {
"name": name,
"ts": ts,
"window_seconds": window,
"info": info,
"cpu": {
"usage_pct": usage_pct,
"modes_pct": mode_pct,
"load1": cpu_raw.get("load1"),
"load5": cpu_raw.get("load5"),
"load15": cpu_raw.get("load15"),
"cores": len(core_usage) or info.get("cpu_cores_logical"),
"core_usage_pct": [c["usage_pct"] for c in core_usage],
"freq_mhz": cpu_raw.get("freq_mhz"),
"freq_max_mhz": cpu_raw.get("freq_max_mhz"),
"context_switches_per_s": ctxt_rate,
"forks_per_s": forks_rate,
"procs_running": cpu_raw.get("procs_running"),
"procs_blocked": cpu_raw.get("procs_blocked"),
"temp_c": cpu_temp,
},
"memory": {
"total_bytes": mem_total,
"used_bytes": mem_used,
"available_bytes": mem.get("available_bytes"),
"used_pct": mem_used_pct,
"cached_bytes": mem.get("cached_bytes"),
"buffers_bytes": mem.get("buffers_bytes"),
"swap_total_bytes": swap_total,
"swap_used_bytes": swap_used,
"swap_used_pct": swap_used_pct,
"committed_bytes": mem.get("committed_bytes"),
},
"disks": {
"filesystems": filesystems,
"worst_used_pct": worst.get("used_pct") if worst else None,
"worst_mount": worst.get("mountpoint") if worst else None,
"read_bytes_per_s": read_total if any_io else None,
"write_bytes_per_s": write_total if any_io else None,
"reads_per_s": reads_total if any_io else None,
"writes_per_s": writes_total if any_io else None,
"util_pct": util_max,
"devices": io_devices,
},
"network": {
"rx_bytes_per_s": rx_total if any_net else None,
"tx_bytes_per_s": tx_total if any_net else None,
"interfaces": net_rows,
},
"gpus": gpus,
"temperatures": {"cpu_c": cpu_temp, "max_c": max_temp, "nvme_max_c": nvme_max,
"sensors": sensors},
"processes": {
"total": processes.get("total"),
"top_cpu": processes.get("top_cpu") or [],
"top_rss": processes.get("top_rss") or [],
},
"agent": {"duration_ms": snapshot.get("duration_ms"), "errors": snapshot.get("errors") or [],
"version": snapshot.get("agent_version")},
}
return Result(payload=payload, samples=samples, events=events)
def fleet_summary(hosts: list[dict[str, Any]]) -> dict[str, Any]:
"""Cross-host totals, computed from live payloads only.
A host whose poll failed is not "0% busy" and is not in the average — it is absent,
and `hosts_live` vs `hosts_total` says so. Never silently include a dead host.
"""
# A host that has never answered is represented by a stub ({"name", "unreachable"}), so
# every field here is read with .get() — one unreachable agent must not 500 the fleet card.
hosts = [h if isinstance(h, dict) else {} for h in hosts]
live = [h for h in hosts if (h.get("cpu") or {}).get("usage_pct") is not None]
weighted = [(h["cpu"]["usage_pct"], (h.get("info") or {}).get("cpu_cores_logical") or 1)
for h in live]
weight_sum = sum(weight for _value, weight in weighted)
cpu_avg = (sum(value * weight for value, weight in weighted) / weight_sum) if weight_sum else None
mem_total = _sum((h.get("memory") or {}).get("total_bytes") for h in hosts)
mem_used = _sum((h.get("memory") or {}).get("used_bytes") for h in hosts)
disk_pcts = [p for h in hosts if (p := (h.get("disks") or {}).get("worst_used_pct"))]
gpus = [g for h in hosts for g in h.get("gpus") or []]
return {
"hosts_total": len(hosts),
"hosts_live": len(live),
"cpu_cores": _sum((h.get("info") or {}).get("cpu_cores_logical") for h in hosts),
"cpu_usage_pct_avg": cpu_avg,
"memory_total_bytes": mem_total,
"memory_used_bytes": mem_used,
"memory_used_pct": _ratio_percent(mem_used, mem_total),
"disk_worst_used_pct": max(disk_pcts) if disk_pcts else None,
"load1_total": _sum((h["cpu"] or {}).get("load1") for h in live),
"net_rx_bytes_per_s": _sum((h.get("network") or {}).get("rx_bytes_per_s") for h in hosts),
"net_tx_bytes_per_s": _sum((h.get("network") or {}).get("tx_bytes_per_s") for h in hosts),
"gpu_count": len(gpus),
"gpu_busy_pct_avg": (sum(g["busy_pct"] for g in gpus if g.get("busy_pct") is not None)
/ max(1, sum(1 for g in gpus if g.get("busy_pct") is not None))
if any(g.get("busy_pct") is not None for g in gpus) else None),
"power_estimate_w": None, # no whole-host power source yet; deliberately n/a, not 0
}
def _sum(values: Any) -> float | None:
numbers = [float(v) for v in values if isinstance(v, int | float)]
return sum(numbers) if numbers else None
def _event(ts: float, host: str, kind: str, severity: str, message: str) -> Any:
from ..store import Event # noqa: PLC0415 - keeps this module import-light
return Event(ts, "host", host, kind, severity, message)
+352
View File
@@ -0,0 +1,352 @@
"""LLM engine source: gufo (llama.cpp-family) and vLLM, read-only.
Ported from llm_monitor, where these field maps were verified against the deployed
servers rather than against documentation — the engines do not export the names people
expect. Do not "fix" a name here without scraping the real thing:
curl -s $GUFO_BASE_URL/metrics | grep -v '^#' | sed -E 's/[{ ].*//' | sort -u
curl -s $VLLM_BASE_URL/metrics | grep -v '^#' | sed -E 's/[{ ].*//' | sort -u
and drop the capture into tests/fixtures/.
Two rules carried over verbatim, because they are the difference between a truthful and a
plausible dashboard:
1. **Absent = n/a, never 0.** A renamed metric yields `{"value": null}` and the UI shows
`n/a` — 0 would claim "no traffic", which is a different statement.
2. **Throughput precedence: native aggregate → measured Δcounter/Δt → estimate → n/a.**
vLLM's native throughput gauge really is an aggregate, so it wins. gufo's are ONE
unlabeled gauge holding the last pass of ONE request, so the summed token counter wins
and the gauge survives only as `*_native`. That asymmetry is deliberate; both cases are
labelled in the UI with their provenance.
"""
from __future__ import annotations
import math
import time
from collections.abc import Sequence
from typing import Any
from ..poller import Result
from ..prom import Prom, field
from ..rates import RateEstimator
from ..store import Sample
GUFO_SERIES: dict[str, tuple[tuple[str, ...], str]] = {
"requests_processing": (("llamacpp:requests_processing", "llama:requests_processing"), "sum"),
"requests_deferred": (("llamacpp:requests_deferred", "llama:requests_deferred"), "sum"),
"kv_cache_usage_ratio": (("llamacpp:kv_cache_usage_ratio", "llama:kv_cache_usage_ratio"), "max"),
# gufo counts prompt cache hits separately: prompt_tokens_total EXCLUDES cached tokens.
"tokens_prompt_total": (("llamacpp:prompt_tokens_total", "llama:prompt_tokens_total",
"llamacpp:tokens_prompt_total"), "sum"),
"tokens_prompt_cached_total": (("llamacpp:prompt_tokens_cached_total",
"llama:prompt_tokens_cached_total"), "sum"),
"tokens_generated_total": (("llamacpp:tokens_predicted_total", "llamacpp:tokens_generated_total",
"llama:tokens_predicted_total", "llama:tokens_generated_total"), "sum"),
# NOT the throughput cell: prefill speed of one request's last pass.
"prompt_tokens_per_second_native": (("llamacpp:prompt_tokens_seconds",
"llamacpp:prompt_tokens_per_second",
"llama:prompt_tokens_seconds"), "avg"),
# NOT the throughput cell: one gauge holding one request's last decode pass.
"decode_tokens_per_second_native": (("llamacpp:predicted_tokens_seconds",
"llamacpp:decode_tokens_per_second",
"llama:predicted_tokens_seconds"), "avg"),
"n_tokens_max": (("llamacpp:n_tokens_max", "llama:n_tokens_max"), "max"),
# GPU context losses (gufo-specific): any non-zero value here is an incident.
"device_lost_total": (("gufo_device_lost_total",), "sum"),
# speculative decoding (MTP on this fleet): accept rate is the useful number.
"spec_drafts_total": (("llamacpp:spec_decode_num_drafts_total",), "sum"),
"spec_draft_tokens_total": (("llamacpp:spec_decode_num_draft_tokens_total",), "sum"),
"spec_accepted_tokens_total": (("llamacpp:spec_decode_num_accepted_tokens_total",), "sum"),
}
VLLM_SERIES: dict[str, tuple[tuple[str, ...], str]] = {
"num_requests_running": (("vllm:num_requests_running",), "sum"),
"num_requests_waiting": (("vllm:num_requests_waiting",), "sum"),
"gpu_cache_usage": (("vllm:kv_cache_usage_perc", "vllm:gpu_cache_usage"), "max"),
"prompt_tokens_total": (("vllm:prompt_tokens_total",), "sum"),
"generation_tokens_total": (("vllm:generation_tokens_total",), "sum"),
# throughput gauges exist only on some builds; absent -> derived from counters/TPOT
"prompt_tokens_per_second": (("vllm:prompt_throughput", "vllm:avg_prompt_throughput_toks_per_s"), "avg"),
"decode_tokens_per_second_native": (("vllm:generation_throughput",
"vllm:avg_generation_throughput_toks_per_s"), "avg"),
"prefix_cache_queries_total": (("vllm:prefix_cache_queries_total",), "sum"),
"prefix_cache_hits_total": (("vllm:prefix_cache_hits_total",), "sum"),
# preemption = a running request kicked back to the queue: the real KV-pressure signal
"num_preemptions_total": (("vllm:num_preemptions_total",), "sum"),
"num_requests_swapped": (("vllm:num_requests_swapped",), "sum"),
}
VLLM_HISTOGRAMS: dict[str, tuple[str, ...]] = {
"time_to_first_token_seconds": ("vllm:time_to_first_token_seconds",),
"time_per_output_token_seconds": ("vllm:time_per_output_token_seconds",
"vllm:inter_token_latency_seconds",
"vllm:request_time_per_output_token_seconds"),
"request_queue_time_seconds": ("vllm:request_queue_time_seconds",),
# not shown directly: mean prompt length / mean prefill time = lifetime prefill speed,
# which is what keeps prompt tok/s off n/a on a build with no rate gauge
"request_prefill_time_seconds": ("vllm:request_prefill_time_seconds",),
"request_prompt_tokens": ("vllm:request_prompt_tokens",),
"e2e_request_latency_seconds": ("vllm:e2e_request_latency_seconds",),
}
#: history series per engine, in the order the LLM page charts them
HISTORY_METRICS: dict[str, tuple[str, ...]] = {
"decode_tokens_per_second": ("decode_tokens_per_second",),
"prompt_tokens_per_second": ("prompt_tokens_per_second",),
"kv_cache_usage": ("kv_cache_usage_ratio", "gpu_cache_usage"),
"requests_running": ("requests_processing", "num_requests_running"),
"requests_waiting": ("requests_deferred", "num_requests_waiting"),
"ttft_seconds": ("time_to_first_token_seconds__mean",),
"ttft_p95_seconds": ("time_to_first_token_seconds__p95",),
"queue_seconds": ("request_queue_time_seconds__mean",),
"prefix_cache_hit_ratio": ("prefix_cache_hit_ratio", "prompt_cache_hit_ratio"),
}
def _ratio(numerator: dict[str, Any], denominator: dict[str, Any], source: str) -> dict[str, Any]:
num, den = numerator["value"], denominator["value"]
if num is None or den is None or den == 0:
return field(None, None)
return field(num / den, source, derived=True)
def _histogram_mean(out: dict[str, Any], key: str) -> float | None:
cell = out.get(key)
mean = cell.get("mean") if isinstance(cell, dict) else None
return mean if isinstance(mean, float) and math.isfinite(mean) else None
def parse_gufo_metrics(text: str, rates: RateEstimator | None = None,
now: float | None = None) -> dict[str, Any]:
"""`rates` enables the Δ-based aggregate rates; omitted, the parse stays pure."""
parsed = Prom(text)
out: dict[str, Any] = {}
for key, (names, agg) in GUFO_SERIES.items():
out[key] = field(parsed.scalar(names, agg), parsed.resolve(names))
def measured(total_key: str) -> dict[str, Any] | None:
"""Δcounter/Δt for one cumulative counter, keyed by the RESOLVED series name so an
alias rename starts a fresh window instead of diffing two different series."""
if rates is None:
return None
source = out[total_key]["source"]
if source is None:
return None
rate, window = rates.rate(source, out[total_key]["value"], now)
if rate is None:
return None
return field(rate, f"computed:Δ{source}/Δt", derived=True, window=window)
# The counters add every slot; gufo's single gauge cannot. Measured therefore wins for
# both speeds here (the inverse of vLLM, where the native gauge is the aggregate) — and
# when no window exists yet the cell stays n/a instead of borrowing one request's speed.
for ui_key, total_key in (
("decode_tokens_per_second", "tokens_generated_total"),
("prompt_tokens_per_second", "tokens_prompt_total"),
):
rate = measured(total_key)
out[ui_key] = rate if rate is not None else field(None, None)
prompt = out["tokens_prompt_total"]["value"]
cached = out["tokens_prompt_cached_total"]["value"] or 0.0
generated = out["tokens_generated_total"]["value"]
parts = [v for v in (prompt, generated) if v is not None]
out["tokens_total"] = (field(sum(parts) + cached, "computed:prompt+cached+generated")
if parts else field(None, None))
out["prompt_cache_hit_ratio"] = (
field(cached / (prompt + cached) if (prompt + cached) > 0 else None,
"computed:cached/(prompt+cached)", derived=True)
if prompt is not None else field(None, None))
out["spec_accept_rate"] = _ratio(out["spec_accepted_tokens_total"],
out["spec_draft_tokens_total"],
"computed:accepted/draft_tokens")
out["spec_drafts_per_request"] = _ratio(out["spec_drafts_total"],
out["spec_draft_tokens_total"],
"computed:drafts/draft_tokens")
return out
def parse_vllm_metrics(text: str, rates: RateEstimator | None = None,
now: float | None = None) -> dict[str, Any]:
parsed = Prom(text)
out: dict[str, Any] = {}
for key, (names, agg) in VLLM_SERIES.items():
out[key] = field(parsed.scalar(names, agg), parsed.resolve(names))
reason_names = ("vllm:num_requests_waiting_by_reason",
"vllm:num_requests_waiting_by_reason_total")
reasons = parsed.by_label(reason_names, "reason")
out["waiting_by_reason"] = {"value": reasons, "source": parsed.resolve(reason_names),
"derived": False}
out["waiting_capacity"] = field(reasons.get("capacity"), parsed.resolve(reason_names))
for ui_key, names in VLLM_HISTOGRAMS.items():
out[ui_key] = parsed.histogram(names)
out[f"{ui_key}__mean"] = field(out[ui_key]["mean"] if out[ui_key] else None,
out[ui_key]["source"] if out[ui_key] else None)
out[f"{ui_key}__p95"] = field(out[ui_key]["p95"] if out[ui_key] else None,
out[ui_key]["source"] if out[ui_key] else None)
tpot = out["time_per_output_token_seconds"]["mean"] if out["time_per_output_token_seconds"] else None
per_seq = (1.0 / tpot) if tpot else None
out["per_request_decode_tokens_per_second"] = field(per_seq, "computed:1/tpot", derived=True)
def measured(key: str, total_key: str) -> dict[str, Any] | None:
if rates is None:
return None
rate, window = rates.rate(key, out[total_key]["value"], now)
if rate is None:
return None
return field(rate, f"computed:Δ{VLLM_SERIES[total_key][0][0]}/Δt", derived=True, window=window)
decode = out["decode_tokens_per_second_native"]["value"]
running = out["num_requests_running"]["value"]
if decode is not None:
out["decode_tokens_per_second"] = field(decode,
out["decode_tokens_per_second_native"]["source"])
else:
# measured beats the running/tpot estimate: it counts real output tokens, the
# estimate assumes every running request is decoding this instant
measured_decode = measured("vllm:generation_tokens_total", "generation_tokens_total")
if measured_decode is not None:
# `or` here would treat a genuine 0 tok/s as missing and print the lifetime
# estimate for an engine that is provably idle
out["decode_tokens_per_second"] = measured_decode
elif per_seq is not None:
out["decode_tokens_per_second"] = field((running or 0) * per_seq,
"computed:running/tpot", derived=True)
else:
out["decode_tokens_per_second"] = field(None, None)
prefill_avg = None
prefill_time = _histogram_mean(out, "request_prefill_time_seconds")
prompt_len = _histogram_mean(out, "request_prompt_tokens")
if prefill_time and prompt_len:
prefill_avg = field(prompt_len / prefill_time,
"computed:mean(request_prompt_tokens)/mean(request_prefill_time)",
derived=True)
if out["prompt_tokens_per_second"]["value"] is None:
measured_prefill = measured("vllm:prompt_tokens_total", "prompt_tokens_total")
if measured_prefill is not None:
out["prompt_tokens_per_second"] = measured_prefill
elif prefill_avg is not None:
out["prompt_tokens_per_second"] = prefill_avg
else:
out["prompt_tokens_per_second"] = field(None, None)
# a lifetime average is a different claim from current throughput — labelled apart
out["prefill_tokens_per_second_lifetime"] = prefill_avg or field(None, None)
parts = [v for v in (out["prompt_tokens_total"]["value"],
out["generation_tokens_total"]["value"]) if v is not None]
out["tokens_total"] = field(sum(parts), "computed:prompt+generated") if parts else field(None, None)
out["prefix_cache_hit_ratio"] = _ratio(out["prefix_cache_hits_total"],
out["prefix_cache_queries_total"],
"computed:hits/queries")
return out
def engine_result(engine: str, base_url: str, text: str, rates: RateEstimator,
*, slots: list[dict[str, Any]] | None = None, now: float | None = None) -> Result:
"""Parse one engine's /metrics into a poll Result (payload + history samples)."""
now = time.time() if now is None else now
if engine == "gufo":
metrics = parse_gufo_metrics(text, rates, now)
active = metrics["requests_processing"]["value"]
else:
metrics = parse_vllm_metrics(text, rates, now)
active = metrics["num_requests_running"]["value"]
parsed = Prom(text)
sessions = _sessions(engine, slots, active, metrics)
payload = {
"engine": engine,
"upstream": base_url,
"scraped_at": now,
"metrics": metrics,
"sessions": sessions,
"series_count": len(parsed.series),
"unparsed": parsed.parse_errors,
}
samples: list[Sample] = []
for metric, candidates in HISTORY_METRICS.items():
value = next((metrics[key]["value"] for key in candidates
if key in metrics and metrics[key]["value"] is not None), None)
if value is not None:
samples.append(Sample("llm", engine, f"llm.{metric}", float(value), now))
return Result(payload=payload, samples=samples)
def _sessions(engine: str, slots: list[dict[str, Any]] | None, active: float | None,
metrics: dict[str, Any]) -> dict[str, Any]:
"""Active sessions, however this engine expresses them.
gufo exposes /slots (one row per parallel slot, with per-slot tokens and speed), so it
gets a real list. vLLM publishes no per-session endpoint in its /metrics, so it gets
counts and says the list is not available rather than inventing rows.
"""
out: dict[str, Any] = {"active": active, "list": [], "source": None, "detail": None}
if engine == "gufo":
if slots:
out["list"] = slots
out["source"] = "gufo /slots"
else:
out["detail"] = ("no /slots answer — active count still comes from "
"llamacpp:requests_processing")
else:
waiting = metrics.get("num_requests_waiting", {}).get("value")
queued = f", {waiting:g} queued" if waiting else ""
out["detail"] = (f"vLLM reports counts, not sessions: {active or 0:g} running{queued}. "
f"Per-request detail is in the latency histograms.")
return out
def fleet_totals(engines: list[dict[str, Any]]) -> dict[str, Any]:
"""Cross-engine sums, computed in the API over engines that answered.
A dead engine keeps its last good values in its own panel but contributes nothing to
a total presented as current — and `engines_live` vs `engines_total` makes that visible.
"""
live = [e for e in engines if e.get("metrics")]
def sum_metric(key: str) -> float | None:
values = [e["metrics"].get(key, {}).get("value") for e in live]
numbers = [v for v in values if isinstance(v, int | float)]
return sum(numbers) if numbers else None
def max_metric(key: str) -> float | None:
values = [e["metrics"].get(key, {}).get("value") for e in live
if isinstance(e["metrics"].get(key, {}).get("value"), int | float)]
return max(values) if values else None
def sum_pairs(keys: Sequence[str]) -> float | None:
"""Sum across engines AND across the per-engine key aliases (gufo calls a running
request `requests_processing`, vLLM `num_requests_running`)."""
values = [sum_metric(key) for key in keys]
numbers = [v for v in values if v is not None]
return sum(numbers) if numbers else None
return {
"engines_total": len(engines),
"engines_live": len(live),
"decode_tokens_per_second": sum_metric("decode_tokens_per_second"),
"prompt_tokens_per_second": sum_metric("prompt_tokens_per_second"),
"requests_active": sum_pairs(("requests_processing", "num_requests_running")),
"requests_waiting": sum_pairs(("requests_deferred", "num_requests_waiting")),
"tokens_total": sum_metric("tokens_total"),
# a real max across both engines' aliases: `a or b` returned gufo's 25% while vLLM
# was sitting at 75%, which is the opposite of a pressure signal
"kv_cache_usage_max": max(
(v for v in (max_metric("kv_cache_usage_ratio"), max_metric("gpu_cache_usage"))
if v is not None),
default=None,
),
"seams": ("gufo's prompt counter excludes cache-hit tokens while vLLM's includes "
"cached prefix tokens, so the summed prompt rate leans slightly low for "
"gufo. Both are counted honestly per engine."),
}
def series_names(engine: str) -> Sequence[str]:
return tuple(GUFO_SERIES if engine == "gufo" else VLLM_SERIES)
+779
View File
@@ -0,0 +1,779 @@
"""NUT (Network UPS Tools) client — the upsd wire protocol, asyncio, no dependencies.
`upsc`/`upspy`/`pynut` are either a subprocess dependency or a stalled third-party lib,
and the protocol is a line protocol with ~10 commands. So: talk to upsd on 3493 directly.
Reading is unauthenticated in stock NUT (`LIST UPS` / `LIST VAR`), so the dashboard works
with a read-only upsd. Instant commands (the battery-test buttons) require a user with
`actions` in upsd.users — that is the ONLY write path in this whole application, and it is
gated three times: `NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, and a confirmation in the UI
(destructive ones require typing the UPS name).
Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape:
-> LIST UPS
<- BEGIN LIST UPS
<- UPS "apc" : APC Smart-UPS
<- END LIST UPS
-> LIST VAR apc
<- BEGIN LIST VAR apc
<- VAR "ups.status" "OL"
<- END LIST VAR apc
-> LIST CMD apc ; <- BEGIN LIST CMD apc / CMD test.battery.start.quick / END LIST CMD apc
bad -> ERR ACCESS-DENIED (auth or permission problem)
ok -> OK ...
"""
from __future__ import annotations
import asyncio
import contextlib
import re
import time
from dataclasses import dataclass, field
from typing import Any
DEFAULT_PORT = 3493
class NutError(Exception):
"""A NUT protocol error. `code` is the upsd error token (ACCESS-DENIED, ...)."""
def __init__(self, message: str, *, code: str | None = None) -> None:
super().__init__(message)
self.code = code
#: upsd error tokens -> what the operator should actually do about them.
ERROR_HINTS = {
"ACCESS-DENIED": "upsd refused: this client is not in an ACL that allows it (upsd.conf `ACL`/"
"`LISTEN`, or the user lacks the privilege in upsd.users)",
"INVALID-PASSWORD": "wrong NUT_PASSWORD for NUT_USER (upsd.users)",
"INVALID-USERNAME": "NUT_USER is not defined in upsd.users",
"INVALID-ARGUMENT": "malformed command or unknown UPS",
"UNKNOWN-UPSNAME": "that UPS is not exported by upsd (check ups.conf driver instance name)",
"UNKNOWN-REQUEST": "this upsd does not implement that command",
"CMD-UNSUPP": "the driver does not implement that command",
"CMD-NOT-BLOCKING": "not a blocking command",
"INVALID-BITMAP": "bad mode bitmap",
"DATA-STALE": "the driver stopped updating this UPS (check nut-driver@<ups> on the NUT host)",
"DRIVER-NOT-CONNECTED": "no driver is connected to that UPS right now",
"PERM-DENIED": "read-only variable, or the user lacks `actions` for that command in upsd.users",
"TOO-MANY-CLIENTS": "upsd MAXCONN reached",
"ALREADY-SET": "value already set",
"FEATURE-NOT-ENABLED": "feature disabled on the driver side",
"FEATURE-NOT-SUPPORTED": "driver does not support that feature",
"TOO-BUSY": "upsd is busy",
"INVALID-VALUE": "value out of range",
}
def _unquote(value: str) -> str:
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
return value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
return value
class NutClient:
"""One connection to upsd. Short-lived by design: connect, read, close.
A persistent connection would be faster but hides restarts of upsd behind a stale
socket, and upsd has a per-client limit (MAXCONN) that a long-lived dashboard would
sit on.
"""
def __init__(self, host: str, port: int = DEFAULT_PORT, *, timeout: float = 4.0,
username: str | None = None, password: str | None = None) -> None:
self.host = host
self.port = port
self.timeout = timeout
self.username = username
self.password = password
self._reader: asyncio.StreamReader | None = None
self._writer = None
async def __aenter__(self) -> NutClient:
await self.connect()
return self
async def __aexit__(self, *_exc: object) -> None:
await self.close()
async def connect(self) -> None:
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port), self.timeout
)
except (TimeoutError, OSError) as exc:
raise NutError(
f"cannot reach upsd at {self.host}:{self.port} ({type(exc).__name__}: {exc})",
code="UNREACHABLE",
) from exc
async def close(self) -> None:
if self._writer is None:
return
with contextlib.suppress(Exception): # logout is best effort; upsd may already be gone
if self.username and self.password:
await self._send("LOGOUT")
await self._read_line(expect_ok=False)
self._writer.close()
with contextlib.suppress(Exception):
await self._writer.wait_closed()
self._writer = None
self._reader = None
# -- plumbing ------------------------------------------------------------ #
async def _send(self, command: str) -> None:
if self._writer is None:
raise NutError("not connected", code="NOCONNECTION")
self._writer.write((command + "\n").encode())
await self._writer.drain()
async def _read_line(self, *, expect_ok: bool = True) -> str:
if self._reader is None:
raise NutError("not connected", code="NOCONNECTION")
try:
raw = await asyncio.wait_for(self._reader.readline(), self.timeout)
except TimeoutError as exc:
raise NutError(f"upsd at {self.host}:{self.port} did not answer in "
f"{self.timeout:g}s", code="TIMEOUT") from exc
except (ConnectionResetError, BrokenPipeError, OSError) as exc:
# upsd dropping the socket (restart, MAXCONN, firewall) is a read failure with a
# name, not a traceback out of the poller
raise NutError(f"upsd at {self.host}:{self.port} dropped the connection "
f"({type(exc).__name__})", code="CLOSED") from exc
if not raw:
raise NutError("upsd closed the connection", code="CLOSED")
line = raw.decode(errors="replace").rstrip("\r\n")
if line.startswith("ERR"):
code = line.split(None, 2)[1] if len(line.split()) > 1 else "UNKNOWN"
raise NutError(f"upsd: {line} — {ERROR_HINTS.get(code, 'see upsd(8)')}", code=code)
if expect_ok and not line.startswith("OK"):
raise NutError(f"unexpected upsd reply: {line!r}", code="UNEXPECTED")
return line
async def _list(self, command: str, kind: str) -> list[list[str]]:
"""Run a LIST command and collect the lines between BEGIN/END."""
begin = f"BEGIN LIST {kind}"
end = f"END LIST {kind}"
await self._send(command)
# LIST replies begin with `BEGIN LIST <kind>`, not `OK`, so this path must not
# demand an OK line — upsd would otherwise be "broken" on every read.
first = await self._read_line(expect_ok=False)
if not first.startswith(begin):
raise NutError(f"expected {begin!r}, got {first!r}", code="UNEXPECTED")
rows: list[list[str]] = []
while True:
line = await self._read_line(expect_ok=False)
if line.startswith(end):
return rows
rows.append(_split_fields(line))
if len(rows) > 4096: # a wedged upsd must not grow the heap
raise NutError(f"{kind} list too long", code="UNEXPECTED")
async def login(self) -> bool:
"""Authenticate for actions. Returns False when no credentials are configured."""
if not self.username or not self.password:
return False
await self._send(f"USER {self.username}")
await self._read_line()
await self._send(f"PASS {self.password}")
await self._read_line()
return True
# -- reads ---------------------------------------------------------------- #
async def list_ups(self) -> list[dict[str, str]]:
rows = await self._list("LIST UPS", "UPS")
out: list[dict[str, str]] = []
for row in rows:
# UPS <name> ["description"]
if len(row) >= 2 and row[0] == "UPS":
name = row[1]
desc = _unquote(" ".join(row[2:])) if len(row) > 2 else ""
out.append({"name": name, "description": desc})
return out
async def list_vars(self, ups: str) -> dict[str, str]:
"""`VAR <ups> <name> <value>` — the UPS name is field 1, the variable is field 2.
Reading field 1 as the name returns the UPS name over and over and every lookup
misses; real upsd was the only way to catch that, so the fake in the tests speaks
the same shape.
"""
rows = await self._list(f"LIST VAR {_quote(ups)}", "VAR")
out: dict[str, str] = {}
for row in rows:
if len(row) >= 3 and row[0] == "VAR":
out[_unquote(row[2])] = _unquote(" ".join(row[3:]))
return out
async def list_cmds(self, ups: str) -> list[str]:
"""`CMD <ups> <command>`."""
rows = await self._list(f"LIST CMD {_quote(ups)}", "CMD")
return [_unquote(row[2]) for row in rows if len(row) >= 3 and row[0] == "CMD"]
async def list_rw_vars(self, ups: str) -> list[str]:
"""`RW <ups> <variable>`."""
rows = await self._list(f"LIST RW {_quote(ups)}", "RW")
return [_unquote(row[2]) for row in rows if len(row) >= 3 and row[0] == "RW"]
# -- writes ---------------------------------------------------------------- #
async def instcmd(self, ups: str, command: str) -> str:
"""Send an instant command. upsd answers `OK Send shorthand command successful`."""
if not (self.username and self.password):
raise NutError("instant commands need NUT_USER/NUT_PASSWORD with `actions` "
"in upsd.users", code="ACCESS-DENIED")
await self._send(f"INSTCMD {_quote(ups)} {_quote(command)}")
return await self._read_line()
def _quote(value: str) -> str:
"""Quote a protocol argument. upsd splits on whitespace and understands C-style quoting."""
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _split_fields(line: str) -> list[str]:
"""Split a protocol line into fields, honouring quoted spans (values contain spaces).
Fields come back unquoted: upsd quotes every variable name and value, and a name that
still carries its quote characters (`"battery.charge"`) silently misses every lookup.
"""
fields: list[str] = []
current: list[str] = []
in_quotes = False
escaped = False
for char in line:
if escaped:
current.append(char)
escaped = False
elif in_quotes and char == "\\":
escaped = True
elif char == '"':
in_quotes = not in_quotes
current.append(char)
elif char == " " and not in_quotes:
if current:
fields.append("".join(current))
current = []
else:
current.append(char)
if current:
fields.append("".join(current))
return [_unquote(field) for field in fields]
# --------------------------------------------------------------------------- #
# Variable mapping
# --------------------------------------------------------------------------- #
# Vendor drivers disagree about names (apc vs cyberpower vs usbhid-ups), so every field
# is a candidate list and an absent value is None, never 0.
VAR_MAP: dict[str, tuple[str, ...]] = {
"mfr": ("ups.mfr",),
"model": ("ups.model",),
"serial": ("ups.serial", "device.serial"),
"firmware": ("ups.firmware", "ups.firmware.aux"),
"driver": ("driver.name",),
"status": ("ups.status",),
"charger_status": ("battery.charger.status",), # NUT 2.8 rich status
"alarm": ("ups.alarm",),
"load_pct": ("ups.load", "ups.load.instead"),
"realpower": ("ups.realpower", "ups.power"),
"realpower_nominal": ("ups.realpower.nominal", "ups.power.nominal"),
"line_voltage": ("input.voltage", "input.voltage.fault"),
"line_voltage_nominal": ("input.voltage.nominal",),
"input_min": ("input.voltage.minimum", "input.low.line"),
"input_max": ("input.voltage.maximum", "input.high.line"),
"transfer_low": ("input.transfer.low",),
"transfer_high": ("input.transfer.high",),
"frequency": ("input.frequency", "input.frequency.nominal"),
"output_voltage": ("output.voltage",),
"output_current": ("output.current",),
"output_frequency": ("output.frequency",),
"charge_pct": ("battery.charge", "battery.capacity:typ"),
"battery_voltage": ("battery.voltage",),
"battery_voltage_nominal": ("battery.voltage.nominal",),
"battery_current": ("battery.current",),
"battery_temperature": ("battery.temperature",),
"battery_type": ("battery.type",),
"battery_runtime": ("battery.runtime", "battery.runtime.low"),
"battery_runtime_low": ("battery.runtime.low",),
"battery_date": ("battery.mfr.date", "battery.date"),
"test_result": ("battery.test.result", "ups.test.result"),
"test_date": ("battery.test.date", "ups.test.date"),
"start_battery": ("ups.start.battery",),
"ups_temperature": ("ups.temperature",),
"efficiency": ("ups.efficiency",),
}
# Anything else worth showing verbatim, if the driver happens to export it.
EXTRA_VAR_PREFIXES = ("battery.test.", "input.transfer.", "output.", "ups.", "battery.")
STATE_FLAGS = {
"OL": ("online", "ok", "On line — utility power is present"),
"OB": ("on battery", "warn", "Running on battery"),
"OB LB": ("low battery", "fail", "On battery and low — shutdown imminent"),
"LB": ("low battery", "fail", "Battery low"),
"RB": ("replace battery", "fail", "The UPS says the battery must be replaced"),
"CHRG": ("charging", "info", "Battery charging"),
"DISCHRG": ("discharging", "warn", "Battery discharging"),
"BYPASS": ("bypass", "warn", "Load is on bypass"),
"OFF": ("off", "fail", "Output is off"),
"NOBAT": ("no battery", "fail", "No battery detected"),
"NULL": ("unknown", "warn", "Driver reports no state"),
"TEST": ("testing", "info", "Self-test in progress"),
"CAL": ("calibrating", "warn", "Battery calibration — runtime estimates are unreliable"),
"OVER": ("overloaded", "fail", "Load exceeds the UPS rating"),
# FUB is the UPS telling us it decided to power off (scheduled shutdown, or the driver's
# own low-battery policy). Missing it reads as "no state" on the one status that matters.
"FUB": ("forced shutdown", "fail", "The UPS has decided to shut down (FUB)"),
"OTHER": ("unknown", "warn", "Driver reports an unclassifiable state"),
"TRIM": ("trimming", "info", "Trimming high input voltage"),
"BOOST": ("boosting", "info", "Boosting low input voltage"),
}
CHARGER_STATUS = {
"ol": ("online", "ok"),
"ol trim": ("trimming", "info"),
"ol boost": ("boosting", "info"),
"chg": ("charging", "info"),
"dchg": ("discharging", "warn"),
"full": ("fully charged", "ok"),
"pover": ("overloaded", "fail"),
"shutd": ("shutting down", "fail"),
"ebypass": ("bypass", "warn"),
"ehazard": ("hazard", "fail"),
"rec": ("in recalibration", "warn"),
"test": ("testing", "info"),
"other": ("unknown", "warn"),
}
@dataclass
class UpsReading:
"""One UPS, as reported by one poll."""
name: str
description: str = ""
vars: dict[str, str] = field(default_factory=dict)
commands: list[str] = field(default_factory=list)
writable: list[str] = field(default_factory=list)
ts: float = field(default_factory=time.time)
def get(self, key: str) -> str | None:
for candidate in VAR_MAP.get(key, (key,)):
value = self.vars.get(candidate)
if value not in (None, "", "0.000000"):
return value
return None
def number(self, key: str) -> float | None:
raw = self.get(key)
if raw is None:
return None
try:
return float(raw)
except ValueError:
return None
@property
def status_flags(self) -> list[str]:
return (self.get("status") or "").split()
def states(self) -> list[dict[str, str]]:
"""Human state list. The 2.8 `battery.charger.status` wins over legacy ups.status
when both exist because it distinguishes trim/boost/full/recalibration."""
out: list[dict[str, str]] = []
charger = (self.get("charger_status") or "").strip().lower()
if charger and charger in CHARGER_STATUS:
label, severity = CHARGER_STATUS[charger]
out.append({"id": charger, "label": label, "severity": severity})
raw = " ".join(self.status_flags)
if raw:
if raw in STATE_FLAGS:
label, severity, detail = STATE_FLAGS[raw]
out.append({"id": raw, "label": label, "severity": severity, "detail": detail})
else:
for flag in self.status_flags:
if flag in STATE_FLAGS:
label, severity, detail = STATE_FLAGS[flag]
out.append({"id": flag, "label": label, "severity": severity, "detail": detail})
return out
@property
def on_battery(self) -> bool:
flags = set(self.status_flags)
if "OL" in flags:
return False
if "OB" in flags:
return True
return (self.get("charger_status") or "").lower() in ("dchg", "rec")
@property
def alarms(self) -> list[str]:
raw = self.get("alarm") or ""
return [part.strip() for part in raw.split(",") if part.strip()]
# --------------------------------------------------------------------------- #
# Health assessment
# --------------------------------------------------------------------------- #
SEVERITY_ORDER = {"ok": 0, "info": 0, "unknown": 1, "warn": 2, "fail": 3}
def normalise_celsius(value: float | None) -> float | None:
"""Turn a driver temperature into °C.
NUT drivers disagree: usbhid-ups usually reports °C, while the powerware/mge heritage
and some CyberPower firmware report kelvin. 300 "°C" is not a melting battery, it is
27 °C, and a health check that fails on it is worse than no check. Above 150 the value
can only be kelvin for a UPS battery, so convert; below that, trust the driver.
"""
if value is None:
return None
return value - 273.15 if value > 150 else value
def _check(name: str, status: str, detail: str, *, metric: str | None = None,
value: float | None = None) -> dict[str, Any]:
return {"name": name, "status": status, "detail": detail, "metric": metric, "value": value}
def assess(reading: UpsReading, *, now: float | None = None,
battery_replacement_years: float = 4.0, min_runtime_seconds: float = 180.0) -> dict[str, Any]:
"""A list of explicit checks, not a mystery score.
Every line says what it looked at and what it saw, because "health: 87" is the kind
of number an operator cannot act on. Absent data is `unknown`, never a pass.
"""
now = time.time() if now is None else now
checks: list[dict[str, Any]] = []
states = reading.states()
if not states:
checks.append(_check("Power path", "unknown", "ups.status is not exported"))
else:
worst = min(states, key=lambda s: -SEVERITY_ORDER.get(s["severity"], 0),
default=states[0])
worst_sev = max((s["severity"] for s in states), key=lambda s: SEVERITY_ORDER.get(s, 0))
checks.append(_check("Power path", worst_sev, ", ".join(s["label"] for s in states),
value=None))
if worst_sev == "fail" and worst is not None:
checks[-1]["detail"] = worst.get("detail", worst["label"])
charge = reading.number("charge_pct")
if charge is None:
checks.append(_check("Charge", "unknown", "battery.charge is not exported"))
elif reading.on_battery:
# On battery this number only falls, and `LB` plus the runtime estimate already say
# whether that is urgent. Reporting it as an error here would double-alarm.
checks.append(_check("Charge", "warn" if charge < 30 else "ok",
f"{charge:.0f}% and discharging", value=charge))
elif charge >= 99:
checks.append(_check("Charge", "ok", f"{charge:.0f}%", value=charge))
elif charge >= 90:
# Plenty of drivers float at 90-94 % forever (chemistry, temperature, float voltage).
# Calling that "degraded" keeps the page permanently amber and teaches people to
# ignore the one badge that is supposed to mean something.
checks.append(_check("Charge", "ok", f"{charge:.0f}% — floating", value=charge))
elif charge >= 50:
checks.append(_check("Charge", "warn", f"{charge:.0f}% — not reaching full charge",
value=charge))
else:
checks.append(_check("Charge", "fail", f"{charge:.0f}% — barely charged", value=charge))
runtime = reading.number("battery_runtime")
if runtime is None:
checks.append(_check("Runtime estimate", "unknown",
"battery.runtime is not exported (the driver cannot estimate it)"))
elif runtime >= min_runtime_seconds * 2:
checks.append(_check("Runtime estimate", "ok", f"{_duration(runtime)} at the current load",
value=runtime))
elif runtime >= min_runtime_seconds:
checks.append(_check("Runtime estimate", "warn", f"only {_duration(runtime)} left",
value=runtime))
else:
checks.append(_check("Runtime estimate", "fail",
f"{_duration(runtime)} — too little to shut down cleanly",
value=runtime))
if "RB" in reading.status_flags:
checks.append(_check("Battery replacement", "fail", "The UPS flags RB — replace the battery"))
else:
age = _battery_age_years(reading, now)
if age is None:
checks.append(_check("Battery age", "unknown",
"no battery.mfr.date / battery.date from this driver"))
elif age >= battery_replacement_years:
checks.append(_check("Battery age", "warn",
f"{age:.1f} years — past the {battery_replacement_years:.0f}-year "
f"replacement window", value=age))
else:
checks.append(_check("Battery age", "ok", f"{age:.1f} years", value=age))
result = reading.get("test_result")
if result is None:
checks.append(_check("Last self-test", "unknown",
"no self-test result reported — run a battery test"))
else:
passed = result.strip().lower() in ("ok", "passed", "pass", "completed")
failed = result.strip().lower() in ("failed", "fail", "error", "aborted", "test incomplete")
checks.append(_check("Last self-test", "ok" if passed else "fail" if failed else "warn",
f"{result}" + (f" ({reading.get('test_date')})" if reading.get("test_date") else "")))
raw_temp = reading.number("battery_temperature")
temp = normalise_celsius(raw_temp)
converted = temp is not None and raw_temp is not None and raw_temp > 150
suffix = " (driver reports kelvin)" if converted else ""
if temp is None:
checks.append(_check("Battery temperature", "unknown", "not reported by this driver"))
elif temp >= 50:
checks.append(_check("Battery temperature", "fail", f"{temp:.1f} °C — VRLA damage risk{suffix}",
value=temp))
elif temp >= 40:
checks.append(_check("Battery temperature", "warn",
f"{temp:.1f} °C — every +10 °C roughly halves VRLA life{suffix}",
value=temp))
elif temp < -10:
checks.append(_check("Battery temperature", "warn",
f"{temp:.1f} °C looks wrong for a battery indoors", value=temp))
else:
checks.append(_check("Battery temperature", "ok", f"{temp:.1f} °C{suffix}", value=temp))
load = reading.number("load_pct")
if load is not None:
status = "ok" if load < 70 else "warn" if load < 90 else "fail"
detail = f"{load:.0f}% of rated capacity"
power = reading.number("realpower")
nominal = reading.number("realpower_nominal")
if power is not None and nominal:
detail += f" ({power:.0f} W of {nominal:.0f} W)"
checks.append(_check("Load", status, detail, value=load))
alarms = reading.alarms
if alarms:
blocking = [a for a in alarms if re.search(r"(LB|RB|OVER|NOBAT|TEMP|SC)\b", a)]
checks.append(_check("Alarms", "fail" if blocking else "warn", ", ".join(alarms)))
line = reading.number("line_voltage")
nominal = reading.number("line_voltage_nominal")
if line is not None and nominal:
drift = abs(line - nominal) / nominal * 100.0
status = "ok" if drift <= 10 else "warn" if drift <= 15 else "fail"
checks.append(_check("Input voltage", status,
f"{line:.1f} V against {nominal:.0f} V nominal ({drift:.1f}%)",
value=line))
low, high = reading.number("transfer_low"), reading.number("transfer_high")
if low is not None and high is not None:
checks[-1]["detail"] += f"; transfer at {low:.0f}-{high:.0f} V"
elif line is not None:
checks.append(_check("Input voltage", "ok", f"{line:.1f} V", value=line))
worst = max((SEVERITY_ORDER.get(c["status"], 0) for c in checks), default=0)
overall = {0: "healthy", 1: "unknown", 2: "degraded", 3: "at risk"}[worst]
return {
"overall": overall,
"severity": {0: "ok", 1: "unknown", 2: "warn", 3: "fail"}[worst],
"checks": checks,
"counts": {
name: sum(1 for c in checks if c["status"] == name)
for name in ("ok", "warn", "fail", "unknown")
},
}
def _battery_age_years(reading: UpsReading, now: float) -> float | None:
raw = reading.get("battery_date")
if not raw:
return None
match = re.match(r"(\d{4})[-/](\d{1,2})(?:[-/](\d{1,2}))?", raw.strip())
if not match:
return None
year, month, day = match.groups()
try:
created = int(year) * 365.25 + (int(month or 1) - 1) * 30.44 + (int(day or 1) - 1)
except ValueError:
return None
now_years = time.gmtime(now).tm_year * 365.25 + (time.gmtime(now).tm_yday - 1)
age = (now_years - created) / 365.25
# A clock skew (battery.date defaults to 1970 or the future) is not a 56-year-old battery.
if age < 0 or age > 30:
return None
return age
def _duration(seconds: float) -> str:
seconds = max(0.0, seconds)
hours, remainder = divmod(int(seconds), 3600)
minutes, secs = divmod(remainder, 60)
if hours:
return f"{hours}h {minutes:02d}m"
if minutes:
return f"{minutes}m {secs:02d}s"
return f"{secs}s"
# --------------------------------------------------------------------------- #
# Instant commands (the app's only write path)
# --------------------------------------------------------------------------- #
RISK_SAFE = "safe"
RISK_CAUTION = "caution"
RISK_DANGEROUS = "dangerous"
#: command -> label, risk, what it really does to the hardware behind the screen.
#: Anything not listed here is refused even if the driver advertises it.
COMMANDS: dict[str, dict[str, str]] = {
"test.battery.start.quick": {
"label": "Quick battery test",
"risk": RISK_CAUTION,
"help": "Transfers the load to battery for a few seconds. Safe on a healthy UPS, "
"but connected equipment is on battery for the duration.",
},
"test.battery.start.deep": {
"label": "Deep battery test",
"risk": RISK_DANGEROUS,
"help": "Drains the battery until it is low or a runtime limit is hit. Everything on "
"this UPS runs on battery for minutes, and a worn battery may fail outright. "
"Run it deliberately, with an eye on the runtime number.",
},
"test.battery.stop": {"label": "Stop battery test", "risk": RISK_SAFE,
"help": "Aborts a running battery test and returns to line power."},
"test.panel.start": {"label": "Panel test", "risk": RISK_SAFE,
"help": "Lights and beeps the front panel for a few seconds."},
"test.system.start": {"label": "Full system self-test", "risk": RISK_CAUTION,
"help": "Vendor self-test; behaviour varies by model and may move "
"the load to battery."},
"beeper.enable": {"label": "Beeper on", "risk": RISK_SAFE, "help": "Re-enable audible alarms."},
"beeper.disable": {"label": "Beeper off", "risk": RISK_SAFE,
"help": "Silences audible alarms. Faults are still reported in this dashboard."},
"beeper.mute": {"label": "Beeper mute", "risk": RISK_SAFE, "help": "Silence the current alarm only."},
"calibrate.start": {
"label": "Start calibration",
"risk": RISK_DANGEROUS,
"help": "Discharges the battery to recalibrate the charge gauge. Long and disruptive; "
"only do this after a full charge and when the load can tolerate it.",
},
"calibrate.stop": {"label": "Stop calibration", "risk": RISK_SAFE, "help": "Ends calibration."},
"reset.input.minmax": {"label": "Reset input min/max", "risk": RISK_SAFE,
"help": "Clears the recorded input voltage extremes."},
"load.on": {"label": "Energise output", "risk": RISK_CAUTION,
"help": "Turns the outlets on."},
"load.off": {"label": "Cut output power", "risk": RISK_DANGEROUS,
"help": "Kills power to everything plugged in, immediately, on battery or not."},
"load.reboot": {"label": "Power-cycle outlets", "risk": RISK_DANGEROUS,
"help": "Cuts and restores output power. Everything attached reboots."},
"shutdown.return": {"label": "Graceful shutdown, then restore", "risk": RISK_DANGEROUS,
"help": "Asks attached systems to shut down and powers back on when "
"utility returns."},
"shutdown.standoff": {"label": "Graceful shutdown, stay off", "risk": RISK_DANGEROUS,
"help": "Shuts attached systems down and stays off."},
"shutdown.stop": {"label": "Cancel shutdown", "risk": RISK_SAFE,
"help": "Aborts a pending shutdown sequence."},
}
def command_advice(command: str) -> dict[str, str] | None:
return COMMANDS.get(command)
def available_commands(advertised: list[str], *, allow_actions: bool,
read_only: bool, has_credentials: bool,
on_battery: bool = False) -> list[dict[str, Any]]:
"""What the UI may offer, with the reason when it may not.
Filtering server-side (not just hiding buttons) is the point: the response says
`enabled: false` plus `reason`, so the page never offers a button that 403s.
"""
out: list[dict[str, Any]] = []
for name in advertised:
advice = COMMANDS.get(name)
if advice is None:
continue
reason = None
if read_only:
reason = "the server is in DASH_READ_ONLY mode"
elif not allow_actions:
reason = "set NUT_ALLOW_ACTIONS=1 to enable UPS commands"
elif not has_credentials:
reason = "NUT_USER / NUT_PASSWORD must name a user with `actions` in upsd.users"
elif on_battery and advice["risk"] == RISK_DANGEROUS:
reason = "refusing a destructive command while the UPS is on battery"
out.append({
"command": name,
"label": advice["label"],
"risk": advice["risk"],
"help": advice["help"],
"enabled": reason is None,
"reason": reason,
"confirm_text": None if advice["risk"] != RISK_DANGEROUS else "DANGER",
})
order = {RISK_SAFE: 0, RISK_CAUTION: 1, RISK_DANGEROUS: 2}
out.sort(key=lambda item: (order[item["risk"]], item["label"]))
return out
def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: Any) -> dict[str, Any]:
"""The JSON one UPS card renders from."""
now = time.time() if now is None else now
health = assess(reading, now=now, **assess_kwargs)
numbers = {key: reading.number(key) for key in VAR_MAP}
# charts must not plot 300 "°C"; the raw string stays visible in the variable dump
for key in ("battery_temperature", "ups_temperature"):
if numbers.get(key) is not None:
numbers[key] = normalise_celsius(numbers[key])
return {
"name": reading.name,
"description": reading.description,
"identity": {
"mfr": reading.get("mfr"),
"model": reading.get("model"),
"serial": reading.get("serial"),
"firmware": reading.get("firmware"),
"driver": reading.get("driver"),
"battery_type": reading.get("battery_type"),
},
"ts": reading.ts,
"status": reading.get("status"),
"charger_status": reading.get("charger_status"),
"states": reading.states(),
"alarms": reading.alarms,
"on_battery": reading.on_battery,
"numbers": numbers,
"health": health,
"advertised_commands": reading.commands,
"writable": reading.writable,
"extra": {
key: value for key, value in sorted(reading.vars.items())
if key.startswith(EXTRA_VAR_PREFIXES) and key not in {v for vs in VAR_MAP.values() for v in vs}
},
}
def history_samples(reading: UpsReading) -> dict[str, float]:
"""The numeric series worth keeping a history of."""
raw = {
"ups.charge_pct": "charge_pct",
"ups.battery_voltage": "battery_voltage",
"ups.battery_current": "battery_current",
"ups.battery_runtime_seconds": "battery_runtime",
"ups.battery_temperature_c": "battery_temperature",
"ups.line_voltage": "line_voltage",
"ups.output_voltage": "output_voltage",
"ups.output_current": "output_current",
"ups.output_frequency": "output_frequency",
"ups.frequency": "frequency",
"ups.load_pct": "load_pct",
"ups.realpower_w": "realpower",
"ups.temperature_c": "ups_temperature",
}
out: dict[str, float] = {}
for metric, key in raw.items():
value = reading.number(key)
if value is None:
continue
if metric.endswith("_temperature_c") or metric == "ups.temperature_c":
value = normalise_celsius(value)
if value is not None:
out[metric] = value
return out
+214
View File
@@ -0,0 +1,214 @@
"""A *labelled* UPS simulator, for development when there is no upsd to talk to.
Enable with `NUT_SIMULATE=1`. It exists so the UPS page can be built and screenshotted
before the hardware is cabled — and so the battery-test buttons can be exercised without
putting a real load on battery. It is never silent about what it is: the payload carries
`simulated: true` and the page shows a watermark on every simulated number.
The model is deliberately small and physical rather than random:
* charge sits at float (100%) while on line, drains at a Peukert-ish rate when on battery
* runtime estimates scale with charge and fall off steeply with load (as they do for real
VRLA packs, which is why the number jumps around while the load moves)
* a quick battery test moves to OB for ~12s and then reports PASSED, so the whole
command → state change → event-log path is exercised
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from typing import Any
from .nut import UpsReading
@dataclass
class SimUps:
"""One fake UPS: nameplate values plus the state a driver would report."""
name: str
model: str
mfr: str
serial: str
nominal_va: float = 1500.0
nominal_w: float = 900.0
battery_cells: int = 2 # 2 x 12V blocks
battery_nominal: float = 24.0
capacity_ah: float = 17.0
load_w: float = 210.0
charge: float = 100.0
on_battery: bool = False
temperature_c: float = 27.0
mfr_date: str = "2022-04-15"
test_result: str = "PASSED"
test_date: str = "2026-09-01"
firmware: str = "UPS 08.3 / IGR 1.00"
driver: str = "usbhid-ups"
_test_until: float = 0.0
_deep_until: float = 0.0
_beeper: bool = True
_alarms: list[str] = field(default_factory=list)
def tick(self, now: float) -> None:
"""Advance the model by wall-clock time since the last tick."""
# a running self-test pulls the load from the wall, whichever test it is
self.on_battery = now < self._test_until or now < self._deep_until
def step(self, dt: float) -> None:
dt = max(0.0, min(dt, 60.0))
if self.on_battery:
# Ah drawn / capacity, with a mild Peukert exponent so a heavy load costs more
hours = dt / 3600.0
load_fraction = max(0.05, self.load_w / self.nominal_w)
drawn = (self.load_w / self.battery_nominal) * hours * (load_fraction ** 0.15)
self.charge = max(0.0, self.charge - (drawn / self.capacity_ah) * 100.0)
elif self.charge < 100.0:
self.charge = min(100.0, self.charge + dt * (100.0 / 14400.0)) # ~4h recharge
self.temperature_c = 27.0 + 0.6 * math.sin(time.time() / 300.0) + self.charge / 400.0
def runtime_seconds(self) -> float:
"""Battery runtime at the present load — the number the UPS itself estimates."""
if not self.on_battery and self.charge >= 99.5:
# Full and on line: what a *fresh* pack would give at this load, which is what
# a UPS reports when it is idle on mains.
usable_ah = self.capacity_ah * 0.9
else:
usable_ah = self.capacity_ah * (self.charge / 100.0)
amps = max(0.5, self.load_w / (self.battery_nominal * 0.9))
load_penalty = max(0.35, 1.0 - (self.load_w / self.nominal_w) ** 2 * 0.45)
return (usable_ah / amps) * 3600.0 * load_penalty
def vars(self, now: float) -> dict[str, str]:
load_pct = self.load_w / self.nominal_w * 100.0
flags = ["OL"] if not self.on_battery else (["OB"] + (["LB"] if self.charge < 20 else []))
if self.charge >= 99.5 and not self.on_battery:
flags.append("CHRG" if self.charge < 99.9 else "")
status = " ".join(part for part in flags if part)
charger = "ol" if not self.on_battery else ("dchg" if self.charge < 99.5 else "ol")
if self.charge >= 99.9 and not self.on_battery:
charger = "full"
volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0)
if self.on_battery else self.battery_nominal * 1.045)
line = 121.4 + 1.1 * math.sin(now / 90.0)
out = {
"ups.mfr": self.mfr,
"ups.model": self.model,
"ups.serial": self.serial,
"ups.firmware": self.firmware,
"ups.status": status,
"battery.charger.status": charger,
"ups.load": f"{load_pct:.1f}",
"ups.realpower": f"{self.load_w:.1f}",
"ups.realpower.nominal": f"{self.nominal_w:.0f}",
"input.voltage": f"{line:.1f}",
"input.voltage.nominal": "120",
"input.frequency": f"{60.0 + 0.02 * math.sin(now / 40.0):.2f}",
"input.transfer.low": "92",
"input.transfer.high": "139",
"output.voltage": f"{line if not self.on_battery else 119.8:.1f}",
"output.current": f"{self.load_w / 120.0:.2f}",
"output.frequency": f"{60.0 + 0.02 * math.sin(now / 40.0):.2f}",
"battery.charge": f"{self.charge:.1f}",
"battery.voltage": f"{volts:.2f}",
"battery.voltage.nominal": f"{self.battery_nominal:.0f}",
"battery.current": f"{self.load_w / volts:.2f}",
"battery.temperature": f"{self.temperature_c + 273.15:.1f}", # drivers report K
"battery.type": "PbAcid: 12V / 9Ah / 17Ah sealed",
"battery.runtime": f"{self.runtime_seconds():.0f}",
"battery.runtime.low": "120",
"battery.mfr.date": self.mfr_date,
"battery.capacity": f"{self.capacity_ah:.0f}",
"battery.test.result": self.test_result,
"battery.test.date": self.test_date,
"driver.name": self.driver,
"driver.parameter.port": "auto",
"ups.id": self.name,
}
if self._alarms:
out["ups.alarm"] = ",".join(self._alarms)
return out
def commands(self) -> list[str]:
return ["test.battery.start.quick", "test.battery.start.deep", "test.battery.stop",
"test.panel.start", "beeper.enable", "beeper.disable", "beeper.mute",
"calibrate.start", "calibrate.stop", "reset.input.minmax",
"load.on", "load.off", "shutdown.stop"]
def run_command(self, command: str, now: float) -> str:
"""Apply a command the way a driver would, so the UI's whole path is real."""
if command == "test.battery.start.quick":
self._test_until = now + 12.0
self.on_battery = True
self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now))
return "OK Start Quick Battery Test"
if command == "test.battery.start.deep":
self._deep_until = now + 90.0
self.on_battery = True
return "OK Start Deep Battery Test"
if command in ("test.battery.stop", "calibrate.stop"):
self._test_until = self._deep_until = 0.0
self.on_battery = False
return "OK Stop Test"
if command == "test.panel.start":
return "OK Start Panel Test"
if command == "beeper.enable":
self._beeper = True
return "OK Beeper enabled"
if command in ("beeper.disable", "beeper.mute"):
self._beeper = False
return "OK Beeper muted"
if command == "calibrate.start":
self._deep_until = now + 180.0
self.on_battery = True
return "OK Start Calibration"
if command == "reset.input.minmax":
return "OK Input min/max reset"
if command == "load.on":
return "OK Load energised"
if command == "load.off":
return "OK Load switched off (simulated)"
if command == "shutdown.stop":
return "OK Shutdown cancelled"
raise ValueError(f"simulated UPS does not implement {command}")
class NutSimulator:
"""Stands in for upsd: same shapes as NutClient reads, no network."""
def __init__(self, spec: str | None = None) -> None:
self.ups = [
SimUps("cyberpower", "PR1500LCDRT2U", "Cyber Power Systems", "CPS-04213",
nominal_va=1500.0, nominal_w=900.0, load_w=210.0, capacity_ah=17.0,
mfr_date="2022-04-15"),
SimUps("apc", "Smart-UPS 1500", "APC", "AS-11902",
nominal_va=1500.0, nominal_w=865.0, load_w=140.0, capacity_ah=8.0,
mfr_date="2019-11-02", driver="usbhid-ups",
test_result="PASSED", test_date="2026-06-14"),
]
self._last = time.time()
async def read(self) -> list[UpsReading]:
now = time.time()
dt = now - self._last
self._last = now
for ups in self.ups:
ups.tick(now)
ups.step(dt)
return [
UpsReading(name=ups.name, description=f"{ups.mfr} {ups.model} (simulated)",
vars=ups.vars(now), commands=ups.commands(),
writable=["beeper.enable"], ts=now)
for ups in self.ups
]
def command(self, ups_name: str, command: str) -> str:
for ups in self.ups:
if ups.name == ups_name:
return ups.run_command(command, time.time())
raise KeyError(f"unknown simulated UPS {ups_name!r}")
def from_env(value: str | None) -> Any:
"""`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd)."""
return NutSimulator() if (value or "").strip().lower() in ("1", "true", "yes", "on") else None
+348
View File
@@ -0,0 +1,348 @@
"""SQLite history: one sample table with three resolutions, plus an event log.
Why SQLite and not Prometheus: the fleet is a handful of boxes, the poll resolution we
need is 1-10s, and a single file the container owns beats running a TSDB. Series are
addressed by `(source, host, metric, res, ts)`:
source the dashboard family — "host", "llm", "ups"
host stable target key ("driveripper", "gufo", "apc-ups1")
metric dotted name, optionally `@stream` for per-device series
(cpu.usage_pct, cpu.usage_pct@core7, disk.used_pct@/srv, gpu.busy_pct@0)
res "raw" | "1m" | "1h"
`maintain()` rolls raw up into 1m, 1m into 1h, and prunes past the retention window, so
the file stays bounded (a 3-host / 3-engine / 2-UPS fleet is a few hundred MB a year,
not a disk emergency).
The connection is guarded by a lock and every method is synchronous: async callers wrap
them in `anyio.to_thread.run_sync`, sync FastAPI handlers run in the threadpool already.
"""
from __future__ import annotations
import json
import math
import sqlite3
import threading
import time
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
RES_RAW, RES_MIN, RES_HOUR = "raw", "1m", "1h"
MINUTE = 60.0
HOUR = 3600.0
_SCHEMA = """
CREATE TABLE IF NOT EXISTS sample (
source TEXT NOT NULL,
host TEXT NOT NULL,
metric TEXT NOT NULL,
res TEXT NOT NULL,
ts REAL NOT NULL,
value REAL NOT NULL,
PRIMARY KEY (source, host, metric, res, ts) ON CONFLICT REPLACE
);
CREATE TABLE IF NOT EXISTS event (
id INTEGER PRIMARY KEY,
ts REAL NOT NULL,
source TEXT NOT NULL,
host TEXT NOT NULL,
kind TEXT NOT NULL,
severity TEXT NOT NULL,
message TEXT NOT NULL,
data TEXT
);
CREATE INDEX IF NOT EXISTS event_ts ON event (ts DESC);
CREATE INDEX IF NOT EXISTS event_source_ts ON event (source, ts DESC);
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
"""
@dataclass(frozen=True)
class Sample:
source: str
host: str
metric: str
value: float
ts: float
@dataclass(frozen=True)
class Event:
ts: float
source: str
host: str
kind: str
severity: str # info | warn | error
message: str
data: dict | None = None
def as_json(self) -> dict:
return {
"ts": self.ts,
"source": self.source,
"host": self.host,
"kind": self.kind,
"severity": self.severity,
"message": self.message,
"data": self.data,
}
class SampleStore:
def __init__(self, path: str | Path, *, raw_keep_hours: float = 48,
minute_keep_days: float = 30, hour_keep_days: float = 400) -> None:
self.path = Path(path)
self.raw_keep_seconds = raw_keep_hours * HOUR
self.minute_keep_seconds = minute_keep_days * 24 * HOUR
self.hour_keep_seconds = hour_keep_days * 24 * HOUR
self._lock = threading.Lock()
if str(self.path) != ":memory:":
self.path.parent.mkdir(parents=True, exist_ok=True)
self._db = sqlite3.connect(str(self.path), check_same_thread=False)
self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute("PRAGMA synchronous=NORMAL") # a lost sample is fine, corruption is not
self._db.execute("PRAGMA busy_timeout=5000")
self._db.executescript(_SCHEMA)
self._db.commit()
def close(self) -> None:
with self._lock:
self._db.close()
# -- writing --------------------------------------------------------------- #
def add(self, source: str, host: str, metric: str, value: float, ts: float | None = None) -> None:
self.add_many([Sample(source, host, metric, value, time.time() if ts is None else ts)])
def add_many(self, samples: Iterable[Sample]) -> int:
"""Insert samples; NaN/inf are dropped (SQLite would store them and charts choke).
Returns the number actually written so the caller can report it, never to fail
the poll over one bad value.
"""
rows = [
(s.source, s.host, s.metric, RES_RAW, s.ts, s.value)
for s in samples
if isinstance(s.value, int | float) and math.isfinite(s.value)
]
if not rows:
return 0
with self._lock, self._db:
self._db.executemany("INSERT OR REPLACE INTO sample VALUES (?,?,?,?,?,?)", rows)
return len(rows)
def log_event(self, event: Event) -> None:
with self._lock, self._db:
self._db.execute(
"INSERT INTO event (ts, source, host, kind, severity, message, data) VALUES (?,?,?,?,?,?,?)",
(event.ts, event.source, event.host, event.kind, event.severity, event.message,
json.dumps(event.data) if event.data else None),
)
# -- reading --------------------------------------------------------------- #
def query(self, source: str, host: str, metric: str, *, res: str = RES_RAW,
start: float = 0.0, end: float | None = None, limit: int = 2000,
tail: bool = False) -> list[tuple[float, float]]:
"""(ts, value) ascending by time within [start, end].
`tail=True` keeps the *newest* `limit` rows instead of the oldest. A window query
that truncates at the head draws the left edge of the chart and silently drops
"now", which is the one point a dashboard has to have.
"""
end = time.time() if end is None else end
where = ("source=? AND host=? AND metric=? AND res=? AND ts>=? AND ts<=?")
args: tuple[object, ...] = (source, host, metric, res, start, end)
with self._lock:
if tail:
cur = self._db.execute(
f"SELECT ts, value FROM (SELECT ts, value FROM sample WHERE {where}"
" ORDER BY ts DESC LIMIT ?) ORDER BY ts ASC",
(*args, limit),
)
else:
cur = self._db.execute(
f"SELECT ts, value FROM sample WHERE {where} ORDER BY ts ASC LIMIT ?",
(*args, limit),
)
return cur.fetchall() # type: ignore[no-any-return]
def pick_res(self, source: str, host: str, metric: str, span: float, max_points: int) -> str:
"""Finest resolution that still fits in `max_points` for this window."""
now = time.time()
start = now - span
finest_with_data: str | None = None
for res in (RES_RAW, RES_MIN, RES_HOUR):
with self._lock:
(count,) = self._db.execute(
"SELECT COUNT(*) FROM sample WHERE source=? AND host=? AND metric=? AND res=? AND ts>=?",
(source, host, metric, res, start),
).fetchone()
if not count:
continue
if finest_with_data is None:
# too many points for the budget is fine — history() thins. Returning a
# resolution with *no* rows in the window reads as a dead sensor.
finest_with_data = res
if count <= max_points:
return res
return finest_with_data or RES_HOUR
def history(self, source: str, host: str, metric: str, *, span: float = 3600.0,
max_points: int = 600) -> list[tuple[float, float]]:
"""Windowed series, thinned to `max_points` so the browser never gets 100k rows."""
res = self.pick_res(source, host, metric, span, max_points)
window_start = time.time() - span
points = self.query(source, host, metric, res=res, start=window_start,
limit=max_points * 4, tail=True)
if not points and res != RES_RAW:
# Rollups lag behind (they only cover buckets that are fully in the past, and a
# dashboard that has been up for ten minutes has none at all). Falling back to
# raw turns "no samples yet" into a real chart instead of a lie by omission.
points = self.query(source, host, metric, res=RES_RAW, start=window_start,
limit=max_points * 4, tail=True)
if len(points) <= max_points:
return points
stride = math.ceil(len(points) / max_points)
thinned = points[::stride]
if thinned[-1] != points[-1]:
thinned.append(points[-1]) # never lose "now" to the thinning
return thinned
def series_names(self, source: str | None = None, host: str | None = None) -> list[str]:
sql = "SELECT DISTINCT metric FROM sample"
args: list[object] = []
clauses = []
if source:
clauses.append("source=?")
args.append(source)
if host:
clauses.append("host=?")
args.append(host)
if clauses:
sql += " WHERE " + " AND ".join(clauses)
with self._lock:
return [row[0] for row in self._db.execute(sql + " ORDER BY metric", args)]
def hosts(self, source: str) -> list[str]:
with self._lock:
return [row[0] for row in self._db.execute(
"SELECT DISTINCT host FROM sample WHERE source=? ORDER BY host", (source,))]
def recent_events(self, limit: int = 50, *, source: str | None = None,
since: float | None = None) -> list[Event]:
sql = "SELECT ts, source, host, kind, severity, message, data FROM event"
args: list[object] = []
clauses: list[str] = []
if source:
clauses.append("source=?")
args.append(source)
if since is not None:
clauses.append("ts>=?")
args.append(since)
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY ts DESC LIMIT ?"
args.append(limit)
out: list[Event] = []
with self._lock:
for ts, src, host, kind, sev, msg, data in self._db.execute(sql, args):
out.append(Event(ts, src, host, kind, sev, msg, json.loads(data) if data else None))
return out
def count(self, *, res: str | None = None) -> int:
sql = "SELECT COUNT(*) FROM sample"
args: tuple[object, ...] = ()
if res:
sql, args = "SELECT COUNT(*) FROM sample WHERE res=?", (res,)
with self._lock:
return int(self._db.execute(sql, args).fetchone()[0])
# -- maintenance ----------------------------------------------------------- #
def maintain(self, now: float | None = None) -> dict[str, int]:
"""Roll up and prune. Safe to call every minute; each step is idempotent.
A bucket is only rolled once its whole window is in the past (+60s slack), so a
rollup never averages a partial minute and then gets rewritten by a later one.
"""
now = time.time() if now is None else now
counts = {"to_minute": 0, "to_hour": 0, "pruned": 0}
with self._lock, self._db:
counts["to_minute"] = self._db.execute(
"INSERT OR REPLACE INTO sample (source, host, metric, res, ts, value)"
" SELECT source, host, metric, ?, CAST(CAST(ts AS INT)/? AS INT)*?, AVG(value)"
" FROM sample WHERE res=? AND ts < ? AND ts >= ?"
" GROUP BY source, host, metric, CAST(CAST(ts AS INT)/? AS INT)",
(RES_MIN, MINUTE, MINUTE, RES_RAW, now - MINUTE - 60,
now - self.raw_keep_seconds, MINUTE),
).rowcount
counts["to_hour"] = self._db.execute(
"INSERT OR REPLACE INTO sample (source, host, metric, res, ts, value)"
" SELECT source, host, metric, ?, CAST(CAST(ts AS INT)/? AS INT)*?, AVG(value)"
" FROM sample WHERE res=? AND ts < ? AND ts >= ?"
" GROUP BY source, host, metric, CAST(CAST(ts AS INT)/? AS INT)",
(RES_HOUR, HOUR, HOUR, RES_MIN, now - HOUR - 60,
now - self.minute_keep_seconds, HOUR),
).rowcount
counts["pruned"] = self._db.execute(
"DELETE FROM sample WHERE (res=? AND ts < ?) OR (res=? AND ts < ?) OR (res=? AND ts < ?)",
(RES_RAW, now - self.raw_keep_seconds,
RES_MIN, now - self.minute_keep_seconds,
RES_HOUR, now - self.hour_keep_seconds),
).rowcount
self._db.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_maintain', ?)", (str(now),))
return counts
def stats(self) -> dict[str, object]:
with self._lock:
by_res = {row[0]: row[1] for row in self._db.execute(
"SELECT res, COUNT(*) FROM sample GROUP BY res")}
(events,) = self._db.execute("SELECT COUNT(*) FROM event").fetchone()
last = self._db.execute("SELECT value FROM meta WHERE key='last_maintain'").fetchone()
size = 0
if str(self.path) != ":memory:" and self.path.exists():
size = self.path.stat().st_size
return {
"path": str(self.path),
"size_bytes": size,
"samples_by_res": by_res,
"samples_total": sum(by_res.values()),
"events": events,
"last_maintain": float(last[0]) if last else None,
}
def delete_series(self, source: str, host: str, metric: str | None = None) -> int:
"""Drop history for a series (or a whole host). Used by tests and by the
'forget this host' escape hatch when a target is renamed for real."""
sql = "DELETE FROM sample WHERE source=? AND host=?"
args: list[object] = [source, host]
if metric:
sql += " AND metric=?"
args.append(metric)
with self._lock, self._db:
return self._db.execute(sql, args).rowcount
def samples_from_flat(source: str, host: str, values: dict[str, float | None],
ts: float, *, prefix: str = "") -> list[Sample]:
"""Flatten a parse result into samples, skipping absent values.
`n/a` in the UI means "this exporter does not report it" — it must not become a
stored 0, which would look identical on a 30-day chart and lie about history.
"""
out: list[Sample] = []
for key, value in values.items():
if value is None or isinstance(value, dict | list | str | bool):
continue
out.append(Sample(source, host, f"{prefix}{key}", float(value), ts))
return out
def select_window(span: float, allowed: Sequence[float]) -> float:
"""Closest allowed window >= span (used to map ?range=1h onto the presets)."""
for candidate in sorted(allowed):
if span <= candidate:
return candidate
return max(allowed)
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab · overview</title>
<link rel="stylesheet" href="/static/dash.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23ff8a3d'/></svg>">
</head>
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<a href="/" aria-current="page">Overview</a>
<a href="/system">System</a>
<a href="/llm">LLM</a>
<a href="/ups">UPS</a>
</nav>
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
</header>
<main>
<div class="page-head">
<div>
<h1>Overview</h1>
<div class="sub" id="summary-line">reading fleet…</div>
</div>
<div class="spacer"></div>
<span class="chip" id="config-chip" hidden>loading config</span>
</div>
<div id="banners" class="stack" style="margin-bottom:.85rem"></div>
<section class="grid auto" aria-label="Dashboards">
<article class="card">
<header><h2>System resources</h2><span class="spacer"></span><span class="dot idle" id="dot-system"></span></header>
<div class="body">
<div class="metrics">
<div class="metric"><div class="label">Hosts answering</div>
<div class="value"><span id="sys-hosts-live">n/a</span><span class="unit" id="sys-hosts-total"></span></div></div>
<div class="metric"><div class="label">CPU average</div>
<div class="value"><span id="sys-cpu">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
<div class="metric"><div class="label">Memory</div>
<div class="value"><span id="sys-mem">n/a</span><span class="unit" id="sys-mem-unit"></span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
<div class="metric"><div class="label">Worst disk</div>
<div class="value"><span id="sys-disk">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
<div class="metric"><div class="label">GPUs</div>
<div class="value sm" id="sys-gpus">n/a</div></div>
</div>
<div class="gap" style="margin-top:.7rem"><a class="action" href="/system" style="text-decoration:none">Open system dashboard →</a></div>
</div>
</article>
<article class="card">
<header><h2>LLM engines</h2><span class="spacer"></span><span class="dot idle" id="dot-llm"></span></header>
<div class="body">
<div class="metrics">
<div class="metric"><div class="label">Engines answering</div>
<div class="value"><span id="llm-live">n/a</span><span class="unit" id="llm-total"></span></div></div>
<div class="metric"><div class="label">Decode</div>
<div class="value"><span id="llm-decode">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Prompt</div>
<div class="value"><span id="llm-prompt">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Active sessions</div>
<div class="value" id="llm-active">n/a</div></div>
<div class="metric"><div class="label">Queued</div>
<div class="value" id="llm-waiting">n/a</div></div>
<div class="metric"><div class="label" title="highest KV-cache use across engines right now">KV cache now</div>
<div class="value"><span id="llm-kv">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
</div>
<div class="gap" style="margin-top:.7rem"><a class="action" href="/llm" style="text-decoration:none">Open LLM dashboard →</a></div>
</div>
</article>
<article class="card">
<header><h2>UPS / battery</h2><span class="spacer"></span><span class="dot idle" id="dot-ups"></span></header>
<div class="body">
<div id="ups-list" class="stack">
<div class="muted">loading…</div>
</div>
<div class="gap" style="margin-top:.7rem"><a class="action" href="/ups" style="text-decoration:none">Open UPS dashboard →</a></div>
</div>
</article>
</section>
<section class="grid wide" style="margin-top:.85rem">
<article class="card">
<header><h2>Fleet</h2><span class="spacer"></span><span class="faint mono" id="fleet-note"></span></header>
<div class="body tight">
<table class="data" id="fleet-table">
<thead><tr><th>Host</th><th>CPU</th><th>Load 1</th><th>Mem used</th><th>Worst disk</th><th>GPU busy</th><th>Temp</th><th>Up</th></tr></thead>
<tbody></tbody>
</table>
<div class="empty-state" id="fleet-empty" hidden>
<h3>No hosts configured</h3>
<p>Run <code>agent/host_agent.py</code> on each machine and list it in
<code>HOST_TARGETS</code>.</p>
</div>
</div>
</article>
<article class="card">
<header><h2>Recent events</h2><span class="spacer"></span>
<span class="faint">transitions only</span></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</article>
</section>
<footer class="pagefoot">
<span id="foot-versions"></span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span>every number is scraped from the box it belongs to · <code>null</code> renders as <code>n/a</code></span>
</footer>
</main>
<script src="/static/charts.js"></script>
<script src="/static/app.js"></script>
<script src="/static/page-index.js"></script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab · LLM</title>
<link rel="stylesheet" href="/static/dash.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23ff8a3d'/></svg>">
</head>
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/llm" aria-current="page">LLM</a>
<a href="/ups">UPS</a>
</nav>
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
</header>
<main>
<div class="page-head">
<div>
<h1>LLM engines</h1>
<div class="sub" id="sub">reading engines…</div>
</div>
<div class="spacer"></div>
<div class="gap"><span class="faint">window</span><div id="range"></div></div>
</div>
<div id="banners" class="stack" style="margin-bottom:.85rem"></div>
<section class="card flat" aria-label="Engine totals">
<div class="body">
<div class="metrics">
<div class="metric"><div class="label">Engines answering</div>
<div class="value"><span id="f-live">n/a</span><span class="unit" id="f-total"></span></div></div>
<div class="metric"><div class="label">Decode (sum)</div>
<div class="value"><span id="f-decode">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Prompt (sum)</div>
<div class="value"><span id="f-prompt">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Active sessions</div>
<div class="value" id="f-active">n/a</div></div>
<div class="metric"><div class="label">Queued</div>
<div class="value" id="f-waiting">n/a</div></div>
<div class="metric"><div class="label">Tokens lifetime</div>
<div class="value"><span id="f-tokens">n/a</span><span class="unit"></span></div></div>
<div class="metric"><div class="label" title="highest KV-cache use across engines right now, not a window peak">KV cache now</div>
<div class="value"><span id="f-kv">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
</div>
<div class="faint" id="seam-note" style="margin-top:.5rem;font-size:11.5px"></div>
</div>
</section>
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Throughput charts">
<article class="card">
<header><h3>Decode throughput</h3><span class="spacer"></span>
<span class="faint">tok/s · every engine, summed slots</span></header>
<div class="body"><div class="chart" id="chart-decode"></div><div class="legend" id="legend-decode"></div></div>
</article>
<article class="card">
<header><h3>Prompt / prefill throughput</h3><span class="spacer"></span>
<span class="faint">tok/s · newly computed tokens</span></header>
<div class="body"><div class="chart" id="chart-prompt"></div><div class="legend" id="legend-prompt"></div></div>
</article>
<article class="card">
<header><h3>KV / prefix cache pressure</h3><span class="spacer"></span><span class="faint">% of cache in use</span></header>
<div class="body"><div class="chart" id="chart-kv"></div><div class="legend" id="legend-kv"></div></div>
</article>
<article class="card">
<header><h3>Sessions &amp; queue</h3><span class="spacer"></span><span class="faint">running vs waiting</span></header>
<div class="body"><div class="chart" id="chart-queue"></div><div class="legend" id="legend-queue"></div></div>
</article>
</section>
<section style="margin-top:.85rem">
<div class="grid cols-2" id="engines"></div>
<div class="empty-state" id="engines-empty" hidden>
<h3>No engines configured</h3>
<p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>.
Either half appears only when its variable is set — a server you do not run
should not have a red panel on your dashboard.</p>
</div>
</section>
<section class="grid cols-2" style="margin-top:.85rem">
<article class="card" id="slots-card">
<header><h3>gufo slots</h3><span class="spacer"></span>
<span class="faint">one row per parallel slot</span></header>
<div class="body tight"><table class="data" id="slots-table">
<thead><tr><th>Slot</th><th>State</th><th>Tokens</th><th>N prompt</th><th>N gen</th>
<th>Cache</th><th>Speed</th></tr></thead><tbody></tbody></table>
<div class="empty-state" id="slots-empty" hidden><p>No <code>/slots</code> rows.</p></div>
</div>
</article>
<article class="card">
<header><h3>Events</h3><span class="spacer"></span><span class="faint">engine up / down</span></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</article>
</section>
<footer class="pagefoot">
<span>each cell carries its provenance: scraped gauge, <span class="mono">Δ10s</span> measured
counter, or lifetime estimate</span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span class="mono" id="foot-note"></span>
</footer>
</main>
<script src="/static/charts.js"></script>
<script src="/static/app.js"></script>
<script src="/static/page-llm.js"></script>
</body>
</html>
+136
View File
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab · system</title>
<link rel="stylesheet" href="/static/dash.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23ff8a3d'/></svg>">
</head>
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system" aria-current="page">System</a>
<a href="/llm">LLM</a>
<a href="/ups">UPS</a>
</nav>
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
</header>
<main>
<div class="page-head">
<div>
<h1>System resources</h1>
<div class="sub" id="sub">reading hosts…</div>
</div>
<div class="spacer"></div>
<div class="gap">
<span class="faint">window</span>
<div id="range"></div>
</div>
</div>
<section class="card flat" aria-label="Fleet totals">
<div class="body">
<div class="metrics">
<div class="metric"><div class="label">Hosts answering</div>
<div class="value"><span id="f-hosts">n/a</span><span class="unit" id="f-hosts-of"></span></div></div>
<div class="metric"><div class="label">CPU average</div>
<div class="value"><span id="f-cpu">n/a</span><span class="unit">%</span></div></div>
<div class="metric"><div class="label">Load 1 (sum)</div>
<div class="value" id="f-load">n/a</div></div>
<div class="metric"><div class="label">Memory in use</div>
<div class="value"><span id="f-mem">n/a</span><span class="unit" id="f-mem-of"></span></div></div>
<div class="metric"><div class="label">Worst filesystem</div>
<div class="value"><span id="f-disk">n/a</span><span class="unit">%</span></div></div>
<div class="metric"><div class="label">Network in</div>
<div class="value"><span id="f-rx">n/a</span><span class="unit" id="f-rx-unit"></span></div></div>
<div class="metric"><div class="label">Network out</div>
<div class="value"><span id="f-tx">n/a</span><span class="unit" id="f-tx-unit"></span></div></div>
<div class="metric"><div class="label">GPU busy (avg)</div>
<div class="value"><span id="f-gpu">n/a</span><span class="unit">%</span></div></div>
<div class="metric"><div class="label">Power</div>
<div class="value sm" id="f-power" title="No whole-host power source is wired up yet; this stays n/a rather than guessing.">n/a</div></div>
</div>
</div>
</section>
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Fleet charts">
<article class="card">
<header><h3>CPU utilisation</h3><span class="spacer"></span><span class="faint">% · per host</span></header>
<div class="body"><div class="chart" id="chart-cpu"></div><div class="legend" id="legend-cpu"></div></div>
</article>
<article class="card">
<header><h3>Memory used</h3><span class="spacer"></span><span class="faint">% of MemAvailable basis</span></header>
<div class="body"><div class="chart" id="chart-mem"></div><div class="legend" id="legend-mem"></div></div>
</article>
<article class="card">
<header><h3>Disk I/O throughput</h3><span class="spacer"></span>
<span class="faint">selected host · all physical devices summed</span></header>
<div class="body"><div class="chart" id="chart-disk"></div><div class="legend" id="legend-disk"></div></div>
</article>
<article class="card">
<header><h3>Network throughput</h3><span class="spacer"></span><span class="faint">selected host · physical interfaces</span></header>
<div class="body"><div class="chart" id="chart-net"></div><div class="legend" id="legend-net"></div></div>
</article>
<article class="card">
<header><h3>GPU utilisation</h3><span class="spacer"></span><span class="faint">one series per host GPU</span></header>
<div class="body"><div class="chart" id="chart-gpu"></div><div class="legend" id="legend-gpu"></div></div>
</article>
<article class="card">
<header><h3>Temperature</h3><span class="spacer"></span><span class="faint">CPU package · °C</span></header>
<div class="body"><div class="chart" id="chart-temp"></div><div class="legend" id="legend-temp"></div></div>
</article>
</section>
<section class="card" style="margin-top:.85rem" aria-label="Series detail">
<header>
<h3>Detail</h3>
<span class="spacer"></span>
<label class="field" for="detail-host">host</label>
<select id="detail-host"></select>
<label class="field" for="detail-metric">metric</label>
<select id="detail-metric"></select>
</header>
<div class="body">
<div class="chart" id="chart-detail" style="height:200px"></div>
<div class="legend" id="legend-detail"></div>
<div class="faint" id="detail-note" style="margin-top:.4rem;font-size:11.5px"></div>
</div>
</section>
<section style="margin-top:.85rem">
<div class="page-head"><h2 style="font-size:.95rem;margin:0">Per host</h2>
<span class="sub">agent-reported values; rates measured over the window in each header</span></div>
<div class="grid auto" id="hosts"></div>
<div class="empty-state" id="hosts-empty" hidden>
<h3>No hosts reporting</h3>
<p>Start the agent on a machine and add it to <code>HOST_TARGETS</code>:</p>
<p class="mono">python3 agent/host_agent.py --host 0.0.0.0 --port 9120 --token …</p>
</div>
</section>
<section class="card" style="margin-top:.85rem">
<header><h3>Events</h3><span class="spacer"></span><span class="faint">unreachable / reachable / reboots</span></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</section>
<footer class="pagefoot">
<span>numbers are measured, not inferred: rates are Δcounter/Δt over the poll window</span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span class="mono" id="foot-note"></span>
</footer>
</main>
<script src="/static/charts.js"></script>
<script src="/static/app.js"></script>
<script src="/static/page-system.js"></script>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab · UPS</title>
<link rel="stylesheet" href="/static/dash.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23ff8a3d'/></svg>">
</head>
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/llm">LLM</a>
<a href="/ups" aria-current="page">UPS</a>
</nav>
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
</header>
<main>
<div class="page-head">
<div>
<h1>UPS &amp; battery health</h1>
<div class="sub" id="sub">reading upsd…</div>
</div>
<div class="spacer"></div>
<div class="gap"><span class="faint">window</span><div id="range"></div></div>
</div>
<div id="banners" class="stack" style="margin-bottom:.85rem"></div>
<section class="grid auto" id="ups-cards"></section>
<div class="empty-state" id="ups-empty" hidden>
<h3>No UPS reporting</h3>
<p>The dashboard speaks the NUT protocol directly to <code>upsd</code>. On the NUT host:</p>
<p class="mono">/etc/ups/upsd.conf → <code>LISTEN 0.0.0.0</code> · <code>PORT 3493</code></p>
<p class="mono">/etc/ups/upsd.users → <code>[dashboards]</code> with
<code>password = …</code> and <code>actions = -</code></p>
<p>then <code>systemctl restart nut-server</code> and open the port to this server.</p>
</div>
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Battery charts">
<article class="card">
<header><h3>Battery charge</h3><span class="spacer"></span><span class="faint">%</span></header>
<div class="body"><div class="chart" id="chart-charge"></div><div class="legend" id="legend-charge"></div></div>
</article>
<article class="card">
<header><h3>Runtime estimate</h3><span class="spacer"></span>
<span class="faint">seconds of backup at the current load</span></header>
<div class="body"><div class="chart" id="chart-runtime"></div><div class="legend" id="legend-runtime"></div></div>
</article>
<article class="card">
<header><h3>Voltages</h3><span class="spacer"></span><span class="faint">battery and incoming line</span></header>
<div class="body"><div class="chart" id="chart-voltage"></div><div class="legend" id="legend-voltage"></div></div>
</article>
<article class="card">
<header><h3>Load on the UPS</h3><span class="spacer"></span><span class="faint">% of rated capacity</span></header>
<div class="body"><div class="chart" id="chart-load"></div><div class="legend" id="legend-load"></div></div>
</article>
</section>
<section class="card" style="margin-top:.85rem">
<header><h3>Power events</h3><span class="spacer"></span>
<span class="faint">state changes, self-test results, commands sent</span></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</section>
<footer class="pagefoot">
<span>commands are the only write path in this app, and every one of them is confirmed here first</span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span class="mono" id="foot-note"></span>
</footer>
</main>
<dialog id="confirm-dialog">
<header><span id="dialog-risk" class="chip">confirm</span><h2 id="dialog-title">Run this command?</h2></header>
<div class="body">
<p id="dialog-help"></p>
<p id="dialog-target" class="mono" style="font-size:12px"></p>
<p id="dialog-confirm-wrap" hidden>Type <strong>DANGER</strong> to enable the button:</p>
<p hidden id="dialog-input-wrap"><input class="confirm" id="dialog-input" type="text"
autocomplete="off" spellcheck="false" placeholder="DANGER"></p>
</div>
<footer>
<button class="ghost" id="dialog-cancel" type="button">Cancel</button>
<button class="action" id="dialog-run" type="button">Run command</button>
</footer>
</dialog>
<script src="/static/charts.js"></script>
<script src="/static/app.js"></script>
<script src="/static/page-ups.js"></script>
</body>
</html>
+341
View File
@@ -0,0 +1,341 @@
/* app.js — shared behaviour for every page: formatting, polling, theme, status.
*
* Two rules the whole UI follows:
* - `null`/`undefined` renders as `n/a`, never as `0`, `""`, or a leftover number.
* A dash that changes to another dash silently is how dashboards lie.
* - a stale value is shown *and labelled*: the dot goes amber/red, `stale 12s` appears,
* and the card dims. Hiding the failure is worse than the failure.
*/
'use strict';
const Dash = (() => {
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
// ---- formatting ---------------------------------------------------------
const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
function fmtBytes(value, digits) {
if (value == null || !isFinite(value)) return null;
let v = Math.abs(value);
let i = 0;
while (v >= 1024 && i < BYTE_UNITS.length - 1) { v /= 1024; i += 1; }
const d = digits != null ? digits : (v >= 100 || i === 0 ? 0 : v >= 10 ? 1 : 2);
return { text: v.toFixed(d), unit: BYTE_UNITS[i] };
}
function bytes(value, digits) {
const parts = fmtBytes(value, digits);
return parts ? `${parts.text} ${parts.unit}` : naText;
}
function rate(value, digits) {
const parts = fmtBytes(value, digits);
return parts ? `${parts.text} ${parts.unit}/s` : naText;
}
function pct(value, digits) {
if (value == null || !isFinite(value)) return naText;
return `${value.toFixed(digits == null ? 1 : digits)}%`;
}
function num(value, digits) {
if (value == null || !isFinite(value)) return naText;
const d = digits == null ? (Math.abs(value) >= 100 ? 0 : Math.abs(value) >= 10 ? 1 : 2) : digits;
return value.toFixed(d);
}
function duration(seconds) {
if (seconds == null || !isFinite(seconds)) return naText;
const s = Math.max(0, seconds);
if (s < 90) return `${Math.round(s)}s`;
const m = s / 60;
if (m < 90) return `${m.toFixed(m < 10 ? 1 : 0)}m`;
const h = m / 60;
if (h < 48) return `${h.toFixed(h < 10 ? 1 : 0)}h`;
const d = h / 24;
if (d < 60) return `${d.toFixed(d < 10 ? 1 : 0)}d`;
return `${(d / 365.25).toFixed(1)}y`;
}
function age(ts) {
if (ts == null) return 'never';
const seconds = Math.max(0, Date.now() / 1000 - ts);
if (seconds < 2) return 'now';
return duration(seconds).replace(/(h|m|s|d|y)$/, '$') + ' ago';
}
const naText = 'n/a';
// ---- DOM helpers --------------------------------------------------------
/** Write text only when it changed: avoids killing transitions and focus. */
function setTxt(el, value) {
if (!el) return;
const text = value == null ? naText : String(value);
if (el.textContent !== text) el.textContent = text;
el.classList.toggle('is-null', value == null);
el.classList.toggle('na', value == null);
}
function setValue(el, value, digits, unit) {
if (!el) return;
const parent = el.parentElement;
if (value == null || !isFinite(value)) {
setTxt(el, naText);
if (parent) parent.classList.add('is-null');
return;
}
if (parent) parent.classList.remove('is-null');
const text = typeof value === 'string' ? value : num(value, digits);
setTxt(el, text);
const unitEl = el.nextElementSibling;
if (unit && unitEl && unitEl.classList.contains('unit')) setTxt(unitEl, unit);
}
function setBar(el, value, kind) {
if (!el) return;
const fill = el.tagName === 'I' ? el : $('i', el);
const pctValue = value == null ? 0 : Math.max(0, Math.min(100, value));
if (fill) fill.style.width = `${pctValue}%`;
const threshold = kind || severityFor(pctValue);
el.classList.remove('ok', 'warn', 'fail');
el.classList.add(threshold);
}
function severityFor(value, warnAt, failAt) {
const warn = warnAt == null ? 75 : warnAt;
const fail = failAt == null ? 90 : failAt;
if (value == null) return '';
if (value >= fail) return 'fail';
if (value >= warn) return 'warn';
return 'ok';
}
function setDot(el, status) {
if (!el) return;
el.classList.remove('ok', 'warn', 'fail', 'idle');
el.classList.add(status || 'idle');
}
function chip(text, kind, title) {
const span = document.createElement('span');
span.className = 'chip' + (kind ? ' ' + kind : '');
span.textContent = text;
if (title) span.title = title;
return span;
}
// ---- networking ---------------------------------------------------------
async function fetchJSON(url, options) {
const response = await fetch(url, Object.assign({ headers: { accept: 'application/json' } },
options || {}));
let body = null;
try { body = await response.json(); } catch (err) { body = null; }
if (!response.ok) {
const detail = body && body.detail
? (typeof body.detail === 'string' ? body.detail : body.detail.detail || JSON.stringify(body.detail))
: `${response.status} ${response.statusText}`;
const error = new Error(detail);
error.status = response.status;
error.body = body;
throw error;
}
return body;
}
/**
* Poll `fn` every `ms`, skipping overlapping runs and pausing when the tab is hidden
* or the user hit pause. Errors are reported, never swallowed silently.
*/
function poll(fn, ms) {
let stopped = false;
let timer = null;
let inFlight = false;
const state = {
get paused() { return state._paused; },
_paused: false,
interval: ms,
setInterval(next) { state.interval = next; if (timer) { clearTimeout(timer); tick(); } },
setPaused(next) {
state._paused = next;
if (next) { if (timer) clearTimeout(timer); timer = null; }
else if (!timer) tick();
},
stop() { stopped = true; if (timer) clearTimeout(timer); },
runNow() { tick(); },
};
async function tick() {
if (stopped || inFlight || state._paused || document.hidden) { schedule(); return; }
inFlight = true;
try { await fn(); } finally { inFlight = false; schedule(); }
}
function schedule() {
if (stopped || state._paused) return;
timer = setTimeout(tick, state.interval);
}
document.addEventListener('visibilitychange', () => { if (!document.hidden && !state._paused) tick(); });
tick();
return state;
}
// ---- chrome -------------------------------------------------------------
function initChrome() {
const root = document.documentElement;
const stored = localStorage.getItem('dash-theme');
const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
root.dataset.theme = stored || (prefersLight ? 'light' : 'dark');
const themeButton = $('#theme-toggle');
if (themeButton) {
const paint = () => {
themeButton.textContent = root.dataset.theme === 'light' ? '☀ Light' : '☾ Dark';
themeButton.setAttribute('aria-pressed', root.dataset.theme === 'light' ? 'true' : 'false');
};
themeButton.addEventListener('click', () => {
root.dataset.theme = root.dataset.theme === 'light' ? 'dark' : 'light';
localStorage.setItem('dash-theme', root.dataset.theme);
paint();
document.dispatchEvent(new CustomEvent('dash:theme'));
});
paint();
}
const clock = $('#clock');
if (clock) {
const tickClock = () => {
const now = new Date();
const target = document.getElementById('sim-flag');
clock.textContent = now.toLocaleTimeString(undefined, { hour12: false });
void target;
};
tickClock();
setInterval(tickClock, 1000);
}
const nav = $('#nav-status');
if (nav) {
const jobs = [
{ key: 'system', label: 'hosts', href: '/system' },
{ key: 'llm', label: 'engines', href: '/llm' },
{ key: 'ups', label: 'UPS', href: '/ups' },
];
nav.innerHTML = jobs.map((job) => `<span class="chip" title="${job.label}">
<span class="dot idle" data-dot="${job.key}"></span>${job.label}</span>`).join('');
const refreshStatus = async () => {
try {
const health = await fetchJSON('/api/health');
for (const job of jobs) {
const dot = nav.querySelector(`[data-dot="${job.key}"]`);
const states = (health.sources || []).filter((s) => s.dashboard === job.key && s.configured);
if (!states.length) { setDot(dot, 'idle'); continue; }
const ok = states.filter((s) => s.ok).length;
setDot(dot, ok === states.length ? 'ok' : ok ? 'warn' : 'fail');
dot.title = `${ok}/${states.length} ${job.label} answering`;
}
} catch (err) {
for (const job of jobs) setDot(nav.querySelector(`[data-dot="${job.key}"]`), 'fail');
}
};
refreshStatus();
setInterval(refreshStatus, 15000);
}
}
/** Range buttons (5m … 30d). Calls onChange(seconds, key). */
function rangeControl(container, current, onChange, choices) {
const options = choices || [['5m', 300], ['15m', 900], ['1h', 3600], ['6h', 21600],
['24h', 86400], ['7d', 604800], ['30d', 2592000]];
let value = current || 3600;
container.className = 'gap';
container.innerHTML = '';
const buttons = options.map(([key, seconds]) => {
const button = document.createElement('button');
button.className = 'ghost';
button.type = 'button';
button.textContent = key;
button.dataset.seconds = String(seconds);
button.setAttribute('aria-pressed', seconds === value ? 'true' : 'false');
button.addEventListener('click', () => {
value = seconds;
buttons.forEach((b) => b.setAttribute('aria-pressed',
Number(b.dataset.seconds) === value ? 'true' : 'false'));
onChange(seconds, key);
});
container.appendChild(button);
return button;
});
return { get value() { return value; } };
}
let toastTimer = null;
function toast(message, kind) {
let host = $('#toasts');
if (!host) {
host = document.createElement('div');
host.id = 'toasts';
host.style.cssText = 'position:fixed;right:1rem;bottom:1rem;z-index:60;display:grid;gap:.4rem';
document.body.appendChild(host);
}
const div = document.createElement('div');
div.className = 'banner ' + (kind || 'info');
div.textContent = message;
host.appendChild(div);
setTimeout(() => div.remove(), kind === 'fail' ? 9000 : 4500);
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { host.remove(); host = null; }, 10000);
}
/** Pause button + Space, wired to a poll handle. */
function bindPause(poller) {
const button = $('#pause');
if (!button || !poller) return;
const paint = () => {
button.setAttribute('aria-pressed', poller.paused ? 'true' : 'false');
button.textContent = poller.paused ? '▶ Paused' : '⏸ Pause';
};
button.addEventListener('click', () => { poller.setPaused(!poller.paused); paint(); });
document.addEventListener('keydown', (event) => {
const tag = (event.target && event.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || event.metaKey || event.ctrlKey) return;
if (event.code === 'Space') { event.preventDefault(); poller.setPaused(!poller.paused); paint(); }
if (event.key === 't' || event.key === 'T') {
const theme = $('#theme-toggle');
if (theme) theme.click();
}
});
paint();
}
/** The "last poll" line every card header shows. */
function staleness(el, state) {
if (!el) return;
if (!state) { setTxt(el, '—'); return; }
if (!state.configured) { setTxt(el, 'not configured'); return; }
if (!state.ok) {
setTxt(el, state.last_ok ? `stale ${duration(ageSeconds(state.last_ok))} · ${state.error || 'down'}`
: (state.error || 'never reached'));
setDot(el.parentElement && el.parentElement.querySelector('.dot'), 'fail');
return;
}
const seconds = ageSeconds(state.last_ok);
setTxt(el, seconds > 30 ? `stale ${duration(seconds)}` : `updated ${duration(seconds)} ago`);
setDot(el.parentElement && el.parentElement.querySelector('.dot'),
seconds > 30 ? 'warn' : 'ok');
}
function ageSeconds(ts) {
return ts == null ? Infinity : Math.max(0, Date.now() / 1000 - ts);
}
/** Cards whose data is older than the poll interval get dimmed + labelled. */
function markStale(card, stale) {
if (!card) return;
card.classList.toggle('stale', !!stale);
}
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
staleness, markStale, severityFor, ageSeconds, naText };
})();
document.addEventListener('DOMContentLoaded', () => Dash.initChrome());
+378
View File
@@ -0,0 +1,378 @@
/* charts.js — hand-rolled SVG time-series charts. No CDN, no chart library.
*
* Coordinate space is real pixels (viewBox = the element's measured size, no
* preserveAspectRatio="none"), which is what keeps axis text crisp: a stretched viewBox
* smears every glyph drawn inside it. Redraw happens on ResizeObserver, not on a timer.
*
* Deliberate behaviours:
* - A series with no points draws nothing and the chart says why. It never draws 0,
* because "no data" and "zero" are different claims.
* - The y-range comes from the data actually plotted, and the labels are generated from
* the same numbers the path used, so a label can never drift from the curve.
* - Gaps are gaps: a sample missing in the middle breaks the line rather than drawing a
* straight lie across the hole.
*/
'use strict';
const CHART_COLORS = ['--series-1', '--series-2', '--series-3', '--series-4',
'--series-5', '--series-6', '--series-7', '--series-8'];
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/** Round a range to a "nice" tick step: 1, 2, 2.5, 5, 10 x 10^n. */
function niceTicks(min, max, count) {
if (!isFinite(min) || !isFinite(max) || min === max) {
const base = isFinite(min) ? min : 0;
return { lo: base - 1, hi: base + 1, step: 1, values: [base - 1, base, base + 1] };
}
const raw = (max - min) / Math.max(1, count);
const mag = Math.pow(10, Math.floor(Math.log10(Math.abs(raw) || 1)));
const norm = raw / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
const lo = Math.floor(min / step) * step;
const hi = Math.ceil(max / step) * step;
const values = [];
for (let v = lo; v <= hi + step * 0.5; v += step) values.push(Number(v.toFixed(10)));
return { lo, hi, step, values };
}
const DEFAULT_FORMAT = (v) => (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(1));
class LineChart {
/**
* container: element to fill (its width is used; height comes from opts.height)
* opts: {height, yFormat, xSpan, yMin, yMax, area, legend, showAxis, min, unit}
*/
constructor(container, opts) {
this.el = typeof container === 'string' ? document.querySelector(container) : container;
this.opts = Object.assign({
height: 150, area: true, legend: true, showAxis: true,
yFormat: DEFAULT_FORMAT, xSpan: 3600, yMin: null, yMax: null, unit: '',
}, opts || {});
this.series = [];
this.hoverTs = null;
this._build();
if (typeof ResizeObserver !== 'undefined') {
this._ro = new ResizeObserver(() => this.draw());
this._ro.observe(this.el);
} else {
window.addEventListener('resize', () => this.draw());
}
}
_build() {
this.el.classList.add('chart');
this.el.style.height = this.opts.height + 'px';
this.el.innerHTML = '';
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('width', '100%');
this.svg.setAttribute('height', this.opts.height);
this.el.appendChild(this.svg);
this.empty = document.createElement('div');
this.empty.className = 'empty';
this.el.appendChild(this.empty);
this.tooltip = document.createElement('div');
this.tooltip.className = 'tooltip';
this.el.appendChild(this.tooltip);
this.svg.addEventListener('mousemove', (e) => this._onMove(e));
this.svg.addEventListener('mouseleave', () => { this.hoverTs = null; this._hideTip(); this._drawCursor(); });
}
/** series: [{name, points: [{ts, value}], color?, yMax?}] — replaces previous data. */
setData(series) {
this.series = (series || []).map((s, i) => ({
name: s.name,
color: s.color || cssVar(CHART_COLORS[i % CHART_COLORS.length]),
points: (s.points || []).filter((p) => p && Number.isFinite(p.value) && Number.isFinite(p.ts))
.sort((a, b) => a.ts - b.ts),
}));
this.draw();
return this;
}
_layout() {
const w = Math.max(80, this.el.clientWidth);
const h = this.opts.height;
const pad = this.opts.showAxis ? { l: 44, r: 8, t: 8, b: 16 } : { l: 2, r: 2, t: 3, b: 3 };
return { w, h, pad, plotW: Math.max(10, w - pad.l - pad.r), plotH: Math.max(10, h - pad.t - pad.b) };
}
_xExtent() {
const span = this.opts.xSpan;
const end = Date.now() / 1000;
return [end - span, end];
}
_yExtent() {
if (this.opts.yMin !== null && this.opts.yMax !== null) {
return [this.opts.yMin, this.opts.yMax];
}
let lo = Infinity, hi = -Infinity;
for (const s of this.series) {
for (const p of s.points) {
if (p.value < lo) lo = p.value;
if (p.value > hi) hi = p.value;
}
}
if (!isFinite(lo)) return [0, 1];
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; return [lo - pad, hi + pad]; }
const margin = (hi - lo) * 0.12;
lo = this.opts.yMin !== null ? this.opts.yMin : lo - margin;
hi = this.opts.yMax !== null ? this.opts.yMax : hi + margin;
return [lo, hi];
}
draw() {
if (!this.el.clientWidth) return;
const plotted = this.series.filter((s) => s.points.length);
const { w, h, pad, plotW, plotH } = this._layout();
this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
this.svg.innerHTML = '';
if (!plotted.length) {
this.empty.textContent = 'no samples yet';
this.empty.style.display = 'grid';
return;
}
this.empty.style.display = 'none';
const [x0, x1] = this._xExtent();
const [ylo, yhi] = this._yExtent();
const ticks = niceTicks(ylo, yhi, 4);
this.scales = {
x: (ts) => pad.l + ((ts - x0) / (x1 - x0)) * plotW,
y: (v) => pad.t + plotH - ((Math.min(Math.max(v, ticks.lo), ticks.hi) - ticks.lo) / (ticks.hi - ticks.lo)) * plotH,
ticks, x0, x1, ylo: ticks.lo, yhi: ticks.hi,
};
const ns = 'http://www.w3.org/2000/svg';
const g = (name) => {
const grp = document.createElementNS(ns, 'g');
grp.setAttribute('class', name);
this.svg.appendChild(grp);
return grp;
};
if (this.opts.showAxis) {
const grid = g('grid');
for (const t of ticks.values) {
const y = this.scales.y(t);
if (y < pad.t - 1 || y > pad.t + plotH + 1) continue;
const line = document.createElementNS(ns, 'line');
line.setAttribute('x1', pad.l); line.setAttribute('x2', pad.l + plotW);
line.setAttribute('y1', y); line.setAttribute('y2', y);
grid.appendChild(line);
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', pad.l - 6); text.setAttribute('y', y + 3.5);
text.setAttribute('text-anchor', 'end');
text.textContent = this.opts.yFormat(t);
grid.appendChild(text);
}
const axis = g('axis');
for (let i = 0; i <= 4; i++) {
const ts = x0 + ((x1 - x0) * i) / 4;
const x = this.scales.x(ts);
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', x); text.setAttribute('y', h - 3);
text.setAttribute('text-anchor', i === 0 ? 'start' : i === 4 ? 'end' : 'middle');
text.textContent = shortTime(ts, x1 - x0);
axis.appendChild(text);
}
}
for (const s of plotted) {
const paths = buildPath(s.points, this.scales, plotH + pad.t);
if (this.opts.area && paths.length) {
for (const seg of paths) {
const area = document.createElementNS(ns, 'path');
area.setAttribute('class', 'area');
area.setAttribute('d', seg.area);
area.setAttribute('fill', s.color);
this.svg.appendChild(area);
}
}
for (const seg of paths) {
const line = document.createElementNS(ns, 'path');
line.setAttribute('class', 'line');
line.setAttribute('d', seg.line);
line.setAttribute('stroke', s.color);
this.svg.appendChild(line);
}
}
const cursor = document.createElementNS(ns, 'g');
cursor.setAttribute('class', 'cursor');
this.svg.appendChild(cursor);
this.cursorEl = cursor;
this._drawCursor();
}
_drawCursor() {
if (!this.cursorEl || !this.scales || this.hoverTs == null) {
if (this.cursorEl) this.cursorEl.innerHTML = '';
return;
}
const { pad } = this._layout();
const x = this.scales.x(this.hoverTs);
this.cursorEl.innerHTML = '';
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x); line.setAttribute('x2', x);
line.setAttribute('y1', pad.t); line.setAttribute('y2', this.opts.height - pad.b);
this.cursorEl.appendChild(line);
}
_onMove(event) {
if (!this.scales) return;
const rect = this.svg.getBoundingClientRect();
const box = (this.svg.getAttribute('viewBox') || '0 0 1 1').split(' ');
const px = ((event.clientX - rect.left) / rect.width) * Number(box[2]);
const { pad, plotW } = this._layout();
const { x0, x1 } = this.scales;
const frac = Math.min(1, Math.max(0, (px - pad.l) / plotW));
const at = x0 + frac * (x1 - x0);
this.hoverTs = at;
this._drawCursor();
this._showTip(at, event);
}
_showTip(at, event) {
const rows = this.series.map((s) => {
const near = nearest(s.points, at);
if (!near) return null;
return `<div class="row"><span class="swatch" style="background:${s.color}"></span>` +
`<span>${escapeHtml(s.name)}</span>` +
`<span class="val">${this.opts.yFormat(near.value)}${this.opts.unit ? ' ' + this.opts.unit : ''}</span></div>`;
}).filter(Boolean);
if (!rows.length) { this._hideTip(); return; }
this.tooltip.innerHTML = `<div class="ts">${fullTime(at)}</div>${rows.join('')}`;
this.tooltip.style.opacity = '1';
const rect = this.el.getBoundingClientRect();
const left = Math.min(rect.width - 150, Math.max(4, event.clientX - rect.left + 12));
this.tooltip.style.left = left + 'px';
this.tooltip.style.top = '8px';
}
_hideTip() { this.tooltip.style.opacity = '0'; }
}
/** Split points into contiguous runs; a gap > 3x the median step breaks the line. */
function buildPath(points, scales, baseline) {
if (!points.length) return [];
const steps = [];
for (let i = 1; i < points.length; i++) steps.push(points[i].ts - points[i - 1].ts);
steps.sort((a, b) => a - b);
const median = steps.length ? steps[Math.floor(steps.length / 2)] : 0;
const maxGap = median > 0 ? median * 3 : Infinity;
const runs = [];
let current = [points[0]];
for (let i = 1; i < points.length; i++) {
if (points[i].ts - points[i - 1].ts > maxGap) { runs.push(current); current = [points[i]]; }
else current.push(points[i]);
}
runs.push(current);
return runs.map((run) => {
let line = '';
run.forEach((p, i) => {
const x = scales.x(p.ts).toFixed(1);
const y = scales.y(p.value).toFixed(1);
line += (i === 0 ? `M${x} ${y}` : ` L${x} ${y}`);
});
const first = scales.x(run[0].ts).toFixed(1);
const last = scales.x(run[run.length - 1].ts).toFixed(1);
const area = `${line} L${last} ${baseline.toFixed(1)} L${first} ${baseline.toFixed(1)} Z`;
return { line, area };
});
}
function nearest(points, ts) {
if (!points.length) return null;
let lo = 0, hi = points.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (points[mid].ts < ts) lo = mid + 1; else hi = mid;
}
const a = points[Math.max(0, lo - 1)], b = points[lo];
return Math.abs(a.ts - ts) < Math.abs(b.ts - ts) ? a : b;
}
function shortTime(ts, span) {
const d = new Date(ts * 1000);
if (span > 86400 * 3) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (span > 86400) return d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' +
d.toLocaleTimeString(undefined, { hour: '2-digit' });
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
function fullTime(ts) {
return new Date(ts * 1000).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
/** Legend with the live last value per series, under the chart. */
/**
* Legend with the live last value per series, under the chart.
*
* `showPeak` adds the window maximum too. Throughput is bursty: a legend that reads
* `0.0 tok/s` next to a chart that clearly went to 60 twice is read as a broken dashboard,
* even though "last poll was idle" is the truth. Saying both keeps it honest and legible.
*/
function renderLegend(container, series, format, showPeak) {
container.innerHTML = series.map((s, i) => {
const color = s.color || cssVar(CHART_COLORS[i % CHART_COLORS.length]);
const points = (s.points || []).filter((p) => Number.isFinite(p.value));
const last = points[points.length - 1];
let peakHtml = '';
if (showPeak && points.length) {
const peak = points.reduce((best, p) => (p.value > best.value ? p : best), points[0]);
if (peak.value !== last.value && peak.value > 0) {
peakHtml = `<span class="lv faint" title="highest value in the window">peak ${format(peak.value)}</span>`;
}
}
return `<span class="item"><span class="swatch" style="background:${color}"></span>` +
`<span>${escapeHtml(s.name)}</span>` +
`<span class="lv">${last ? format(last.value) : 'n/a'}</span>${peakHtml}</span>`;
}).join('');
}
/** Column heat bars for per-core usage: 48 cores read as a pattern, not a table. */
function renderCores(container, values, format) {
const f = format || ((v) => (v == null ? 'n/a' : v.toFixed(0) + '%'));
container.innerHTML = (values || []).map((v, i) => {
const pct = v == null ? 0 : Math.max(0, Math.min(100, v));
const hue = pct > 85 ? 'var(--fail)' : pct > 60 ? 'var(--warn)' : 'var(--accent)';
return `<span class="core" title="core ${i}: ${escapeHtml(f(v))}">` +
`<i style="height:${pct.toFixed(1)}%;background:${hue}"></i></span>`;
}).join('');
}
/** Small non-interactive sparkline for cards where a full chart would shout. */
function sparkline(container, points, opts) {
const o = Object.assign({ height: 34, color: cssVar('--series-1'), min: null, max: null }, opts || {});
const pts = (points || []).filter((p) => p && Number.isFinite(p.value));
const w = Math.max(60, container.clientWidth || 160);
const h = o.height;
container.innerHTML = '';
if (pts.length < 2) {
container.innerHTML = '<div class="faint mono" style="font-size:11px">no history</div>';
return;
}
const xs = pts.map((p) => p.ts), ys = pts.map((p) => p.value);
const x0 = Math.min(...xs), x1 = Math.max(...xs);
let lo = o.min !== null ? o.min : Math.min(...ys);
let hi = o.max !== null ? o.max : Math.max(...ys);
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; }
const sx = (ts) => ((ts - x0) / (x1 - x0 || 1)) * (w - 2) + 1;
const sy = (v) => h - 2 - ((v - lo) / (hi - lo)) * (h - 4);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${sx(p.ts).toFixed(1)} ${sy(p.value).toFixed(1)}`).join(' ');
const svg = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none"
role="img" aria-label="trend from ${lo.toFixed(2)} to ${hi.toFixed(2)}">
<path d="${d} L${sx(x1).toFixed(1)} ${h} L${sx(x0).toFixed(1)} ${h} Z" fill="${o.color}" opacity=".14"/>
<path d="${d}" fill="none" stroke="${o.color}" stroke-width="1.4"/></svg>`;
container.innerHTML = svg;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, renderCores, sparkline, cssVar,
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
+442
View File
@@ -0,0 +1,442 @@
/* Homelab dashboards — design system.
*
* No CDN, no webfonts, no framework: this runs on a homelab network that may have no
* route to the internet, and a font that fails to load is a page that reflows in front
* of you. Everything here is system fonts and hand-written SVG.
*
* Two themes via [data-theme] on <html> (persisted in localStorage), defaulting to the
* OS preference. All colours are tokens; nothing in a component hardcodes a colour.
*/
:root {
--bg: #0d0f13;
--bg-raised: #14171e;
--bg-sunken: #0a0c0f;
--border: #232836;
--border-strong: #333b4d;
--text: #e7eaf0;
--text-muted: #8b94a7;
--text-faint: #5c6478;
--accent: #ff8a3d;
--accent-soft: rgba(255, 138, 61, 0.14);
--ok: #3fb950;
--ok-soft: rgba(63, 185, 80, 0.15);
--warn: #d29922;
--warn-soft: rgba(210, 153, 34, 0.16);
--fail: #f85149;
--fail-soft: rgba(248, 81, 73, 0.16);
--info: #58a6ff;
--info-soft: rgba(88, 166, 255, 0.15);
--series-1: #ff8a3d;
--series-2: #58a6ff;
--series-3: #3fb950;
--series-4: #bc8cff;
--series-5: #f778ba;
--series-6: #d29922;
--series-7: #39c5cf;
--series-8: #8b94a7;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.25);
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--header-h: 58px;
}
[data-theme="light"] {
--bg: #f4f5f8;
--bg-raised: #ffffff;
--bg-sunken: #e9ebf0;
--border: #dfe3ea;
--border-strong: #c3cad6;
--text: #16191f;
--text-muted: #5b6473;
--text-faint: #8a93a3;
--accent: #d2691e;
--accent-soft: rgba(210, 105, 30, 0.12);
--ok: #1a7f37;
--ok-soft: rgba(26, 127, 55, 0.12);
--warn: #9a6700;
--warn-soft: rgba(154, 103, 0, 0.12);
--fail: #cf222e;
--fail-soft: rgba(207, 34, 46, 0.1);
--info: #0969da;
--info-soft: rgba(9, 105, 218, 0.1);
--series-1: #d2691e;
--series-2: #0969da;
--series-3: #1a7f37;
--series-4: #8250df;
--series-5: #bf3989;
--series-6: #9a6700;
--series-7: #1b7c84;
--series-8: #5b6473;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 6px 20px rgba(16, 24, 40, 0.06);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 var(--sans);
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---- header ------------------------------------------------------------- */
.masthead {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 1.1rem;
height: var(--header-h);
padding: 0 1.25rem;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: .55rem;
font-weight: 650;
letter-spacing: .01em;
color: var(--text);
white-space: nowrap;
}
.brand:hover { text-decoration: none; }
.brand-mark {
width: 22px; height: 22px;
border-radius: 6px;
background: linear-gradient(140deg, var(--accent), color-mix(in srgb, var(--accent) 45%, var(--info)));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .12);
display: grid;
place-items: center;
font-size: 12px;
color: #14171e;
font-weight: 800;
}
nav.tabs { display: flex; gap: .15rem; margin-inline-start: .4rem; }
nav.tabs a {
padding: .38rem .7rem;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-weight: 550;
display: flex;
align-items: center;
gap: .4rem;
}
nav.tabs a:hover { background: var(--bg-sunken); color: var(--text); text-decoration: none; }
nav.tabs a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.masthead-right { margin-inline-start: auto; display: flex; align-items: center; gap: .55rem; }
.clock { font-family: var(--mono); font-size: 12.5px; color: var(--text-muted); }
@media (max-width: 900px) { .clock { display: none; } }
button.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: var(--radius-sm);
padding: .32rem .6rem;
font: 550 12.5px var(--sans);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: .35rem;
}
button.ghost:hover { border-color: var(--border-strong); color: var(--text); background: var(--bg-sunken); }
button.ghost[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* ---- layout ------------------------------------------------------------- */
main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
.page-head {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.page-head h1 { font-size: 1.15rem; margin: 0; font-weight: 650; letter-spacing: .01em; }
.page-head .sub { color: var(--text-muted); font-size: 12.5px; }
.page-head .spacer { margin-inline-start: auto; }
.grid { display: grid; gap: .85rem; }
.grid.auto { grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); }
.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(min(480px, 100%), 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); }
.grid.wide { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
@media (max-width: 1150px) { .grid.wide { grid-template-columns: 1fr; } }
.stack { display: grid; gap: .85rem; align-content: start; }
/* ---- cards -------------------------------------------------------------- */
.card {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.card > header {
display: flex;
align-items: center;
gap: .55rem;
padding: .6rem .8rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-raised) 70%, var(--bg-sunken));
}
.card > header h2, .card > header h3 {
margin: 0; font-size: 12.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
color: var(--text-muted);
}
.card > header .spacer { margin-inline-start: auto; }
.card > .body { padding: .8rem; }
.card > .body.tight { padding: .45rem .8rem .6rem; }
.card.flat { box-shadow: none; }
.cardset { display: grid; gap: .85rem; }
/* ---- metrics ------------------------------------------------------------ */
.metrics { display: grid; gap: .7rem .9rem; grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); }
.metric { min-width: 0; }
.metric .label {
font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.metric .value {
font-family: var(--mono); font-size: 1.3rem; font-weight: 600; line-height: 1.25;
display: flex; align-items: baseline; gap: .22rem; row-gap: .1rem;
/* the provenance chip has to be able to drop under the number: with nowrap it painted
over the neighbouring column, which reads as a wrong value on the next metric */
flex-wrap: wrap;
}
.metric .value > span, .metric .value > .unit { white-space: nowrap; }
.metric .value .unit { font-size: .62em; color: var(--text-muted); font-weight: 500; }
.metric .value.sm { font-size: 1.02rem; }
.metric .note { font-size: 11px; color: var(--text-faint); }
.metric .value.is-null { color: var(--text-faint); font-size: 1rem; }
.prov {
font-family: var(--mono); font-size: 9.5px; letter-spacing: .02em;
color: var(--text-faint); border: 1px solid var(--border); border-radius: 999px;
padding: 0 .32rem; margin-inline-start: .28rem; vertical-align: 2px; white-space: nowrap;
}
.na { color: var(--text-faint); }
/* ---- bars, dots, chips -------------------------------------------------- */
.bar {
position: relative; height: 7px; border-radius: 999px; background: var(--bg-sunken);
overflow: hidden; border: 1px solid var(--border);
}
.bar > i {
position: absolute; inset: 0 auto 0 0; width: 0; border-radius: 999px;
background: var(--accent); transition: width .35s ease;
}
.bar.ok > i { background: var(--ok); }
.bar.warn > i { background: var(--warn); }
.bar.fail > i { background: var(--fail); }
.bar.thin { height: 5px; }
.bar.tall { height: 10px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-faint); flex: none; }
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
.dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-soft); }
.dot.fail { background: var(--fail); box-shadow: 0 0 0 3px var(--fail-soft); }
.dot.idle { background: var(--text-faint); }
.chip {
display: inline-flex; align-items: center; gap: .3rem;
font-size: 11px; font-weight: 600; letter-spacing: .02em;
padding: .16rem .45rem; border-radius: 999px;
background: var(--bg-sunken); border: 1px solid var(--border); color: var(--text-muted);
white-space: nowrap;
}
.chip.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
.chip.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
.chip.fail { color: var(--fail); background: var(--fail-soft); border-color: color-mix(in srgb, var(--fail) 35%, transparent); }
.chip.info { color: var(--info); background: var(--info-soft); border-color: color-mix(in srgb, var(--info) 35%, transparent); }
.chip.accent { color: var(--accent); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 35%, transparent); }
.banner {
display: flex; gap: .55rem; align-items: center;
padding: .5rem .7rem; border-radius: var(--radius-sm);
background: var(--bg-sunken); border: 1px solid var(--border);
color: var(--text-muted); font-size: 12.5px;
}
.banner.warn { background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 35%, transparent); color: var(--warn); }
.banner.fail { background: var(--fail-soft); border-color: color-mix(in srgb, var(--fail) 35%, transparent); color: var(--fail); }
.banner.ok { background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 35%, transparent); color: var(--ok); }
.banner.info { background: var(--info-soft); border-color: color-mix(in srgb, var(--info) 30%, transparent); color: var(--info); }
.banner code { font-family: var(--mono); font-size: 11.5px; background: var(--bg-sunken); padding: 0 .25rem; border-radius: 3px; }
/* simulated data is watermarked everywhere it appears */
.sim-badge {
font: 700 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase;
color: var(--warn); background: var(--warn-soft);
border: 1px dashed color-mix(in srgb, var(--warn) 55%, transparent);
padding: .2rem .4rem; border-radius: 4px;
}
/* ---- tables ------------------------------------------------------------- */
table.data { width: 100%; border-collapse: collapse; font-size: 12.5px; }
table.data th, table.data td { padding: .34rem .5rem; text-align: right; white-space: nowrap; }
table.data th:first-child, table.data td:first-child { text-align: left; }
table.data thead th {
font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint);
border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg-raised);
}
table.data tbody tr + tr td { border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); }
table.data tbody tr:hover td { background: var(--bg-sunken); }
table.data td.name { font-weight: 600; }
table.data .num { font-family: var(--mono); }
table.data .mute { color: var(--text-muted); }
table.data.scroll { display: block; overflow: auto; max-height: 320px; }
/* ---- charts ------------------------------------------------------------- */
.chart { position: relative; width: 100%; }
.chart svg { display: block; width: 100%; height: 100%; overflow: visible; }
.chart .axis text { fill: var(--text-faint); font-family: var(--mono); font-size: 10px; }
.chart .axis line, .chart .grid line { stroke: var(--border); stroke-width: 1; }
.chart .grid line { stroke-dasharray: 2 4; opacity: .75; }
.chart .zero line { stroke: var(--border-strong); }
.chart .line { fill: none; stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; }
.chart .area { opacity: .16; }
.chart .cursor line { stroke: var(--text-faint); stroke-width: 1; stroke-dasharray: 3 3; }
.chart .empty {
position: absolute; inset: 0; display: grid; place-items: center;
color: var(--text-faint); font-size: 12px; pointer-events: none;
}
.legend { display: flex; flex-wrap: wrap; gap: .35rem .8rem; font-size: 11.5px; color: var(--text-muted); }
.legend .item { display: inline-flex; align-items: center; gap: .35rem; }
.legend .swatch { width: 9px; height: 9px; border-radius: 2px; }
.legend .lv { font-family: var(--mono); color: var(--text); }
.tooltip {
position: absolute; z-index: 30; pointer-events: none; opacity: 0;
background: color-mix(in srgb, var(--bg-raised) 95%, transparent);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
box-shadow: var(--shadow); padding: .35rem .5rem; font-size: 11.5px; min-width: 118px;
transition: opacity .12s ease;
}
.tooltip .ts { color: var(--text-faint); font-family: var(--mono); font-size: 10.5px; margin-bottom: .18rem; }
.tooltip .row { display: flex; align-items: center; gap: .35rem; }
.tooltip .row .val { margin-inline-start: auto; font-family: var(--mono); }
/* per-core usage heat bars */
.cores { display: grid; grid-template-columns: repeat(auto-fill, minmax(9px, 1fr)); gap: 2px; }
.cores .core { height: 22px; border-radius: 2px; background: var(--bg-sunken); position: relative; }
.cores .core > i { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 2px; }
/* ---- lists -------------------------------------------------------------- */
ul.events { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
ul.events li {
display: grid; grid-template-columns: 62px 1fr; gap: .55rem; align-items: baseline;
padding: .3rem .1rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
font-size: 12.5px;
}
ul.events li:last-child { border-bottom: 0; }
ul.events time { font-family: var(--mono); font-size: 11px; color: var(--text-faint); }
ul.events .sev { font-weight: 600; }
ul.events .sev.info { color: var(--text-muted); }
ul.events .sev.warn { color: var(--warn); }
ul.events .sev.error { color: var(--fail); }
ul.checks { list-style: none; margin: 0; padding: 0; display: grid; gap: .4rem; }
ul.checks li { display: grid; grid-template-columns: 18px 1fr; gap: .5rem; align-items: start; }
ul.checks .what { font-weight: 600; font-size: 12.5px; }
ul.checks .detail { color: var(--text-muted); font-size: 12px; }
ul.checks .mark { font-family: var(--mono); font-size: 12px; text-align: center; }
ul.checks li.ok .mark { color: var(--ok); }
ul.checks li.warn .mark { color: var(--warn); }
ul.checks li.fail .mark { color: var(--fail); }
ul.checks li.unknown .mark { color: var(--text-faint); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: .18rem .8rem; font-size: 12.5px; }
.kv dt { color: var(--text-faint); }
.kv dd { margin: 0; font-family: var(--mono); text-align: right; overflow-wrap: anywhere; }
/* ---- buttons ------------------------------------------------------------ */
button.action {
font: 600 12.5px var(--sans);
padding: .42rem .7rem; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-sunken); color: var(--text);
cursor: pointer;
}
button.action:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button.action:disabled { opacity: .5; cursor: not-allowed; }
button.action.caution { border-color: color-mix(in srgb, var(--warn) 45%, var(--border-strong)); }
button.action.dangerous { border-color: color-mix(in srgb, var(--fail) 45%, var(--border-strong)); color: var(--fail); }
button.action.dangerous:hover:not(:disabled) { background: var(--fail-soft); border-color: var(--fail); color: var(--fail); }
fieldset.commands { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .6rem .7rem; margin: 0; }
fieldset.commands legend { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); padding: 0 .3rem; }
fieldset.commands .row { display: flex; flex-wrap: wrap; gap: .4rem; }
input[type="text"].confirm {
font-family: var(--mono); background: var(--bg-sunken); color: var(--text);
border: 1px solid var(--fail); border-radius: var(--radius-sm); padding: .35rem .5rem; width: 11ch;
}
/* ---- dialog ------------------------------------------------------------- */
dialog {
border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--bg-raised);
color: var(--text); padding: 0; max-width: 34rem; width: calc(100% - 2rem); box-shadow: var(--shadow);
}
dialog::backdrop { background: rgba(0, 0, 0, .55); }
dialog h2 { margin: 0; font-size: 1rem; }
dialog > header { padding: .8rem .9rem; border-bottom: 1px solid var(--border); display: flex; gap: .5rem; align-items: center; }
dialog .body { padding: .9rem; font-size: 13px; color: var(--text-muted); }
dialog footer { padding: .7rem .9rem; border-top: 1px solid var(--border); display: flex; gap: .5rem; justify-content: flex-end; }
/* ---- misc --------------------------------------------------------------- */
.muted { color: var(--text-muted); }
.faint { color: var(--text-faint); }
.mono { font-family: var(--mono); }
.nowrap { white-space: nowrap; }
.right { text-align: right; }
.center { text-align: center; }
.gap { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
/* `[hidden]` must beat any display set on the same element — .banner is display:flex, so a
* hidden banner used to occupy a grey strip above the KPIs on every card. */
[hidden] { display: none !important; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.wrap { overflow-wrap: anywhere; }
.empty-state { padding: 2rem 1rem; text-align: center; color: var(--text-muted); }
.empty-state h3 { margin: 0 0 .4rem; font-size: 1rem; color: var(--text); }
.empty-state code { font-family: var(--mono); font-size: 12px; background: var(--bg-sunken); padding: .1rem .3rem; border-radius: 4px; }
.empty-state ul { text-align: left; display: inline-block; margin: .6rem 0 0; }
.stale { opacity: .62; }
.stale-flag { color: var(--warn); font-size: 11px; }
select, input[type="text"] {
background: var(--bg-sunken); color: var(--text); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: .3rem .45rem; font: 500 12.5px var(--sans);
}
label.field { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); }
footer.pagefoot {
margin-top: 1.6rem; padding-top: .8rem; border-top: 1px solid var(--border);
color: var(--text-faint); font-size: 11.5px; display: flex; gap: 1rem; flex-wrap: wrap;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
+171
View File
@@ -0,0 +1,171 @@
/* Overview page: one /api/summary call, plus the config-driven setup hints. */
'use strict';
const { $, fetchJSON, setTxt, setValue, setBar, setDot, bytes, pct, num, duration, poll } = Dash;
async function loadConfig() {
try {
const config = await fetchJSON('/api/config');
const banners = [];
if (!config.system.enabled) {
banners.push(['warn', 'System dashboard has no targets — set <code>HOST_TARGETS</code> ' +
'(run <code>agent/host_agent.py</code> on each host first).']);
}
if (config.system.target_problems && config.system.target_problems.length) {
banners.push(['fail', 'Bad HOST_TARGETS entries: ' +
config.system.target_problems.map((p) => `<code>${DashCharts.escapeHtml(p)}</code>`).join(' ')]);
}
if (!config.llm.gufo.enabled && !config.llm.vllm.enabled) {
banners.push(['warn', 'No LLM engines configured — set <code>GUFO_BASE_URL</code> and/or ' +
'<code>VLLM_BASE_URL</code>.']);
}
if (!config.ups.enabled) {
banners.push(['warn', 'No NUT server configured — set <code>NUT_HOST</code> (upsd must ' +
'have a <code>LISTEN</code> line).']);
} else if (!config.ups.actions_enabled) {
banners.push(['info', 'UPS commands are off — set <code>NUT_ALLOW_ACTIONS=1</code> to enable them.']);
} else if (!config.ups.auth && !config.ups.simulated) {
// the simulator needs no credentials: asking for them there sends the operator fixing
// something that is not broken
banners.push(['info', 'UPS commands need credentials: set <code>NUT_USER</code>/' +
'<code>NUT_PASSWORD</code> with <code>actions</code> in <code>upsd.users</code>.']);
}
if (config.ups.simulated) {
banners.push(['warn', '<strong>SIMULATED UPS DATA.</strong> <code>NUT_SIMULATE=1</code> is set: ' +
'the UPS numbers are a development model, not your batteries.']);
}
const host = $('#banners');
host.innerHTML = banners.map(([kind, html]) =>
`<div class="banner ${kind}">${html}</div>`).join('');
$('#foot-versions').textContent =
`dashboards ${config.app.version} · poll system ${config.system.interval_seconds}s · ` +
`llm ${config.llm.interval_seconds}s · ups ${config.ups.interval_seconds}s · ` +
`uptime ${duration(config.app.started_at ? Date.now() / 1000 - config.app.started_at : null)}` +
(config.app.read_only ? ' · read-only' : '');
return config;
} catch (err) {
return null;
}
}
function renderEvents(events) {
const list = $('#events');
if (!events || !events.length) {
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">Nothing has changed ' +
'since this server started — the log only records transitions.</li>';
return;
}
list.innerHTML = events.map((event) => {
const when = new Date(event.ts * 1000).toLocaleTimeString(undefined,
{ hour: '2-digit', minute: '2-digit', hour12: false });
return `<li><time>${when}</time><span><span class="sev ${event.severity}">${event.host}</span> ` +
`${DashCharts.escapeHtml(event.message.replace(event.host + ':', '').trim() || event.kind)}</span></li>`;
}).join('');
}
function renderSummary(summary) {
const system = summary.system || {};
const fleet = system.fleet || {};
setDot($('#dot-system'), system.enabled ? (fleet.hosts_live === fleet.hosts_total ? 'ok'
: fleet.hosts_live ? 'warn' : 'fail') : 'idle');
setTxt($('#sys-hosts-live'), fleet.hosts_live == null ? 'n/a' : String(fleet.hosts_live));
setTxt($('#sys-hosts-total'), fleet.hosts_total == null ? '' : `of ${fleet.hosts_total}`);
setValue($('#sys-cpu'), fleet.cpu_usage_pct_avg, 0);
setBar($('#sys-cpu').closest('.metric').querySelector('.bar'), fleet.cpu_usage_pct_avg);
if (fleet.memory_used_bytes != null) {
const used = Dash.fmtBytes(fleet.memory_used_bytes, 0);
const total = Dash.fmtBytes(fleet.memory_total_bytes, 0);
// "200 / 253 GiB", not "200 GiB of 253 GiB": the card column is narrow and the long form
// ran into the worst-disk number next to it
setTxt($('#sys-mem'), total ? `${used.text}/${total.text}` : used.text);
setTxt($('#sys-mem-unit'), used.unit);
} else {
setTxt($('#sys-mem'), 'n/a');
setTxt($('#sys-mem-unit'), '');
}
setBar($('#sys-mem').closest('.metric').querySelector('.bar'), fleet.memory_used_pct);
setValue($('#sys-disk'), fleet.disk_worst_used_pct, 0);
setBar($('#sys-disk').closest('.metric').querySelector('.bar'), fleet.disk_worst_used_pct,
Dash.severityFor(fleet.disk_worst_used_pct, 80, 92));
setTxt($('#sys-gpus'), fleet.gpu_count == null ? 'n/a'
: `${fleet.gpu_count}${fleet.gpu_busy_pct_avg != null ? ' · ' + num(fleet.gpu_busy_pct_avg, 0) + '%' : ''}`);
const llm = summary.llm || {};
const engines = llm.fleet || {};
setDot($('#dot-llm'), !llm.enabled ? 'idle'
: (engines.engines_live === engines.engines_total ? 'ok' : engines.engines_live ? 'warn' : 'fail'));
setTxt($('#llm-live'), engines.engines_live == null ? 'n/a' : String(engines.engines_live));
setTxt($('#llm-total'), engines.engines_total == null ? '' : `of ${engines.engines_total}`);
setValue($('#llm-decode'), engines.decode_tokens_per_second, 1);
setValue($('#llm-prompt'), engines.prompt_tokens_per_second, 0);
setTxt($('#llm-active'), engines.requests_active == null ? 'n/a' : num(engines.requests_active, 0));
setTxt($('#llm-waiting'), engines.requests_waiting == null ? 'n/a' : num(engines.requests_waiting, 0));
setValue($('#llm-kv'), engines.kv_cache_usage_max == null ? null : engines.kv_cache_usage_max * 100, 0);
setBar($('#llm-kv').closest('.metric').querySelector('.bar'),
engines.kv_cache_usage_max == null ? null : engines.kv_cache_usage_max * 100,
Dash.severityFor(engines.kv_cache_usage_max == null ? null : engines.kv_cache_usage_max * 100, 75, 90));
const ups = summary.ups || {};
setDot($('#dot-ups'), !ups.enabled ? 'idle'
: (ups.ups || []).some((u) => u.overall === 'at risk') ? 'fail'
: (ups.ups || []).some((u) => u.overall !== 'healthy') ? 'warn' : 'ok');
const list = $('#ups-list');
if (!ups.enabled) {
list.innerHTML = '<div class="muted">No NUT server configured.</div>';
} else if (!(ups.ups || []).length) {
list.innerHTML = '<div class="muted">No UPS answering from this upsd yet.</div>';
} else {
list.innerHTML = ups.ups.map((item) => {
const kind = item.overall === 'healthy' ? 'ok' : item.overall === 'at risk' ? 'fail'
: item.overall === 'degraded' ? 'warn' : '';
return `<div class="gap" style="justify-content:space-between">
<span class="gap"><span class="chip ${kind}">${DashCharts.escapeHtml(item.name)}</span>
<span class="mono faint">${DashCharts.escapeHtml(item.status || 'no state')}</span>
${ups.simulated ? '<span class="sim-badge">sim</span>' : ''}</span>
<span class="mono">${item.charge_pct == null ? 'n/a' : num(item.charge_pct, 0) + '%'} ·
${item.load_pct == null ? 'n/a' : num(item.load_pct, 0) + '% load'} ·
${item.runtime_seconds == null ? 'n/a' : duration(item.runtime_seconds) + ' runtime'}</span>
</div>`;
}).join('');
}
const rows = system.hosts || [];
const tbody = $('#fleet-table tbody');
$('#fleet-empty').hidden = rows.length > 0;
if (!rows.length) { tbody.innerHTML = ''; return; }
tbody.innerHTML = rows.map((host) => {
const gpus = (host.gpu_busy_pct || []).filter((v) => v != null);
return `<tr>
<td class="name">${DashCharts.escapeHtml(host.name)}</td>
<td class="num">${host.cpu_usage_pct == null ? 'n/a' : num(host.cpu_usage_pct, 0) + '%'}</td>
<td class="num mute">${host.load1 == null ? 'n/a' : num(host.load1, 2)}</td>
<td class="num">${host.mem_used_pct == null ? 'n/a' : num(host.mem_used_pct, 0) + '%'}</td>
<td class="num">${host.disk_worst_used_pct == null ? 'n/a' : num(host.disk_worst_used_pct, 0) + '%'}</td>
<td class="num">${gpus.length ? num(gpus[0], 0) + '%' : '<span class="mute">—</span>'}</td>
<td class="num">${host.temp_c == null ? '<span class="mute">n/a</span>' : num(host.temp_c, 0) + '°C'}</td>
<td class="num mute">${host.uptime_seconds == null ? 'n/a' : duration(host.uptime_seconds)}</td>
</tr>`;
}).join('');
}
async function refresh() {
try {
const summary = await fetchJSON('/api/summary');
const total = (summary.system.hosts || []).length;
const live = summary.system.fleet ? summary.system.fleet.hosts_live : 0;
const engines = summary.llm.fleet ? `${summary.llm.fleet.engines_live}/${summary.llm.fleet.engines_total} engines` : 'no engines';
const ups = summary.ups.ups ? `${summary.ups.ups.length} UPS` : 'no UPS';
$('#summary-line').textContent = `${live}/${total} hosts · ${engines} · ${ups} · ` +
`${(summary.recent_events || []).length} recent events`;
renderSummary(summary);
renderEvents(summary.recent_events);
} catch (err) {
$('#summary-line').textContent = `summary failed: ${err.message}`;
}
}
document.addEventListener('DOMContentLoaded', async () => {
await loadConfig();
await refresh();
poll(refresh, 10000);
});
+382
View File
@@ -0,0 +1,382 @@
/* LLM dashboard.
*
* Every throughput cell shows where its number came from, because "78 tok/s" means
* different things depending on whether it was scraped from an aggregate gauge, measured as
* Δtokens/Δt across all slots, or estimated from TPOT over the process lifetime. The tag is
* generated from the cell's `source`, never hardcoded in markup, so a parser change cannot
* leave a stale label behind.
*/
'use strict';
const { $, fetchJSON, setTxt, setValue, setBar, setDot, num, pct, duration, poll,
severityFor } = Dash;
const state = { range: 3600, panels: new Map(), charts: {}, poller: null, pollMs: 2000 };
const CELLS = [
{ key: 'decode_tokens_per_second', label: 'Decode', unit: 'tok/s', digits: 1,
tip: 'Aggregate generation speed across every busy slot.' },
{ key: 'prompt_tokens_per_second', label: 'Prompt', unit: 'tok/s', digits: 1,
tip: 'Prefill speed for newly computed tokens.' },
{ key: 'sessions', label: 'Sessions', unit: 'active', digits: 0 },
{ key: 'queued', label: 'Queued', unit: 'waiting', digits: 0 },
{ key: 'cache', label: 'KV cache', unit: '%', digits: 0, bar: true },
{ key: 'ttft_mean', label: 'TTFT mean', unit: 'ms', digits: 0 },
{ key: 'ttft_p95', label: 'TTFT p95', unit: 'ms', digits: 0 },
{ key: 'tpot_mean', label: 'TPOT mean', unit: 'ms', digits: 1 },
{ key: 'queue_mean', label: 'Queue mean', unit: 'ms', digits: 0 },
{ key: 'cache_hit', label: 'Prefix cache hit', unit: '%', digits: 0 },
{ key: 'spec_accept', label: 'Spec accept', unit: '%', digits: 0 },
{ key: 'tokens_total', label: 'Tokens lifetime', unit: '', digits: 0, human: true },
{ key: 'preemptions', label: 'Preemptions', unit: '', digits: 0 },
{ key: 'device_lost', label: 'GPU context lost', unit: '', digits: 0 },
];
function cellValue(engine, metrics, sessions, key) {
const get = (k) => (metrics[k] && metrics[k].value != null ? metrics[k] : null);
switch (key) {
case 'sessions': {
const value = sessions && sessions.active != null ? sessions.active : null;
return { value, source: 'session count', derived: false };
}
case 'queued': {
const cell = get('requests_deferred') || get('num_requests_waiting');
return cell ? { value: cell.value, source: cell.source, derived: false } : { value: null };
}
case 'cache': {
const cell = get('kv_cache_usage_ratio') || get('gpu_cache_usage');
return cell ? { value: cell.value * 100, source: cell.source, derived: false } : { value: null };
}
case 'ttft_mean': {
const cell = get('time_to_first_token_seconds__mean');
return cell ? { value: cell.value * 1000, source: cell.source, derived: false } : { value: null };
}
case 'ttft_p95': {
const cell = get('time_to_first_token_seconds__p95');
return cell ? { value: cell.value * 1000, source: cell.source, derived: false } : { value: null };
}
case 'tpot_mean': {
const cell = get('time_per_output_token_seconds__mean');
return cell ? { value: cell.value * 1000, source: cell.source, derived: false } : { value: null };
}
case 'queue_mean': {
const cell = get('request_queue_time_seconds__mean');
return cell ? { value: cell.value * 1000, source: cell.source, derived: false } : { value: null };
}
case 'cache_hit': {
const cell = get('prefix_cache_hit_ratio') || get('prompt_cache_hit_ratio');
return cell ? { value: cell.value * 100, source: cell.source, derived: true } : { value: null };
}
case 'spec_accept': {
const cell = get('spec_accept_rate');
return cell ? { value: cell.value * 100, source: cell.source, derived: true } : { value: null };
}
case 'tokens_total': {
const cell = get('tokens_total');
return cell ? { value: cell.value, source: cell.source, derived: true } : { value: null };
}
case 'preemptions': {
const cell = get('num_preemptions_total');
return cell ? { value: cell.value, source: cell.source, derived: false } : { value: null };
}
case 'device_lost': {
const cell = get('device_lost_total');
return cell ? { value: cell.value, source: cell.source, derived: false } : { value: null };
}
default: {
const cell = get(key);
return cell ? { value: cell.value, source: cell.source, derived: cell.derived,
window: cell.window } : { value: null };
}
}
}
/** The provenance tag: Δ10.0s for measured rates, the formula for estimates, nothing for a
* plain scraped gauge (which would only add noise). */
function provenance(cell) {
if (!cell || !cell.source) return '';
if (!String(cell.source).startsWith('computed:')) return '';
if (cell.window) return `Δ${cell.window.toFixed(1)}s`;
const text = String(cell.source).replace('computed:', '');
return text.length > 16 ? text.slice(0, 15) + '…' : text;
}
function humanNumber(value) {
if (value == null || !isFinite(value)) return null;
if (value >= 1e9) return { text: (value / 1e9).toFixed(2), unit: 'B tok' };
if (value >= 1e6) return { text: (value / 1e6).toFixed(2), unit: 'M tok' };
if (value >= 1e3) return { text: (value / 1e3).toFixed(1), unit: 'k tok' };
return { text: value.toFixed(0), unit: 'tok' };
}
function enginePanel(engine) {
const card = document.createElement('article');
card.className = 'card';
card.dataset.engine = engine;
card.innerHTML = `
<header>
<span class="dot idle"></span>
<h3>${DashCharts.escapeHtml(engine)}</h3>
<span class="chip upstream mono" hidden></span>
<span class="spacer"></span>
<span class="faint mono updated"></span>
</header>
<div class="body stack">
<div class="banner err" hidden></div>
<div class="metrics">${CELLS.map((cell) => `
<div class="metric" data-cell="${cell.key}" ${cell.tip ? `title="${cell.tip}"` : ''}>
<div class="label">${cell.label}</div>
<div class="value${cell.digits === 0 ? '' : ''}"><span data-v>n/a</span><span class="unit">${cell.unit}</span></div>
${cell.bar ? '<div class="bar" style="margin-top:.25rem"><i></i></div>' : ''}
</div>`).join('')}</div>
<div class="faint mono sessions-note" style="font-size:11.5px"></div>
<div class="faint mono debug" style="font-size:11px"></div>
</div>`;
return card;
}
function renderPanel(card, enginePayload, stateRow) {
const metrics = enginePayload.metrics || {};
const sessions = enginePayload.sessions || {};
const q = (sel) => card.querySelector(sel);
const dot = q('.dot');
setDot(dot, stateRow && stateRow.ok ? (Dash.ageSeconds(stateRow.last_ok) > 15 ? 'warn' : 'ok') : 'fail');
setTxt(q('.updated'), stateRow ? (stateRow.last_ok == null
? (stateRow.error || 'never reached')
: (Dash.ageSeconds(stateRow.last_ok) > 15 ? `stale ${duration(Dash.ageSeconds(stateRow.last_ok))}`
: `updated ${duration(Dash.ageSeconds(stateRow.last_ok))}`)) : '—');
Dash.markStale(card, stateRow && (!stateRow.ok || Dash.ageSeconds(stateRow.last_ok) > 15));
const banner = q('.banner.err');
if (stateRow && !stateRow.ok) {
banner.hidden = false;
banner.className = 'banner fail err';
banner.textContent = stateRow.error || 'engine is not answering';
} else {
banner.hidden = true;
}
for (const cell of CELLS) {
const wrapper = q(`[data-cell="${cell.key}"]`);
if (!wrapper) continue;
const value = cellValue(enginePayload.engine, metrics, sessions, cell.key);
const valueEl = wrapper.querySelector('[data-v]');
const unitEl = wrapper.querySelector('.unit');
if (value.value == null) {
setTxt(valueEl, 'n/a');
setTxt(unitEl, ''); // "n/a %" reads like a measurement of nothing
wrapper.classList.add('is-null');
} else {
wrapper.classList.remove('is-null');
if (cell.human) {
const parts = humanNumber(value.value);
setTxt(valueEl, parts ? parts.text : 'n/a');
setTxt(unitEl, parts ? parts.unit : '');
} else {
setTxt(valueEl, num(value.value, cell.digits));
setTxt(unitEl, cell.unit);
}
}
let tag = wrapper.querySelector('.prov');
const label = provenance(value);
if (label) {
if (!tag) {
tag = document.createElement('span');
tag.className = 'prov';
wrapper.querySelector('.value').appendChild(tag);
}
setTxt(tag, label);
tag.title = `${value.source || ''}${value.window ? ` · measured over ${value.window.toFixed(1)}s` : ''}`;
} else if (tag) {
tag.remove();
}
if (cell.bar) setBar(wrapper.querySelector('.bar'), value.value,
severityFor(value.value, 75, 90));
if (cell.key === 'device_lost' && value.value != null && value.value > 0) {
wrapper.querySelector('.value').classList.add('is-null');
wrapper.classList.add('fail');
wrapper.title = `GPU context has been lost ${value.value} times — an incident, not a stat`;
}
}
const note = q('.sessions-note');
setTxt(note, sessions.detail || `${sessions.active == null ? 'n/a' : sessions.active} active sessions`);
const upstream = q('.chip.upstream');
if (enginePayload.upstream) {
upstream.hidden = false;
upstream.textContent = enginePayload.upstream.replace(/^https?:\/\//, '');
upstream.title = enginePayload.upstream;
}
setTxt(q('.debug'), enginePayload.unparsed != null
? `${enginePayload.series_count} series · ${enginePayload.unparsed} unparsed`
: '');
}
function renderSlots(payloads) {
const gufo = payloads.find((p) => p.engine === 'gufo');
const rows = gufo && gufo.sessions ? gufo.sessions.list || [] : [];
const tbody = $('#slots-table tbody');
$('#slots-empty').hidden = rows.length > 0;
$('#slots-card').hidden = !gufo;
tbody.innerHTML = rows.map((slot) => {
const id = slot.id != null ? slot.id : slot.slot != null ? slot.slot : '—';
const stateText = slot.state || (slot.idle === true ? 'idle' : slot.idle === false ? 'busy' : '—');
const withText = (slot.with_messages && slot.with_messages.length) || slot.with_message;
const nPrompt = slot.n_prompt != null ? slot.n_prompt : (slot.prompt_tokens != null ? slot.prompt_tokens : null);
const nPredict = slot.n_predict != null ? slot.n_predict : (slot.generated_tokens != null ? slot.generated_tokens : null);
const cache = slot.cache != null ? slot.cache : slot.cache_usable;
const speed = slot.decode_per_second || slot.tokens_per_second || null;
const cell = (value, digits) => `<td class="num">${value == null ? '<span class="mute">—</span>' : num(value, digits == null ? 0 : digits)}</td>`;
return `<tr>
<td class="name mono" style="font-size:11.5px">${DashCharts.escapeHtml(id)}</td>
<td>${stateText === 'busy' || stateText === 'processing' ? '<span class="chip ok">busy</span>'
: stateText === 'idle' ? '<span class="chip">idle</span>'
: `<span class="chip warn">${DashCharts.escapeHtml(stateText)}</span>`}</td>
<td>${withText ? '<span class="chip info">has msg</span>' : '<span class="mute">—</span>'}</td>
${cell(nPrompt)}${cell(nPredict)}${cell(cache)}${cell(speed, 1)}</tr>`;
}).join('');
}
function makeCharts() {
const common = { height: 165, xSpan: state.range };
state.charts.decode = new DashCharts.LineChart('#chart-decode', Object.assign({}, common, { yMin: 0 }));
state.charts.prompt = new DashCharts.LineChart('#chart-prompt', Object.assign({}, common, { yMin: 0 }));
state.charts.kv = new DashCharts.LineChart('#chart-kv', Object.assign({}, common,
{ yMin: 0, yMax: 100, yFormat: (v) => num(v, 0) }));
state.charts.queue = new DashCharts.LineChart('#chart-queue', Object.assign({}, common,
{ yMin: 0, yFormat: (v) => num(v, 0) }));
}
function pointsFor(response, engine, metric) {
const found = (response.series || []).find((s) => s.host === engine && s.metric === `llm.${metric}`);
return found ? found.points.map((p) => ({ ts: p.ts, value: p.value })) : [];
}
async function refreshCharts(engineNames) {
if (!engineNames.length) return;
const list = engineNames.join(',');
const [decode, prompt, queue] = await Promise.all([
fetchJSON(`/api/llm/history?engines=${list}&metrics=decode_tokens_per_second&range=${state.range}`)
.catch(() => null),
fetchJSON(`/api/llm/history?engines=${list}&metrics=prompt_tokens_per_second&range=${state.range}`)
.catch(() => null),
fetchJSON(`/api/llm/history?engines=${list}&metrics=requests_running,requests_waiting,` +
`kv_cache_usage&range=${state.range}`).catch(() => null),
]);
if (decode) {
const series = engineNames.map((engine) => ({ name: engine,
points: pointsFor(decode, engine, 'decode_tokens_per_second') }));
state.charts.decode.setData(series);
DashCharts.renderLegend($('#legend-decode'), series, (v) => num(v, 1) + ' tok/s', true);
}
if (prompt) {
const series = engineNames.map((engine) => ({ name: engine,
points: pointsFor(prompt, engine, 'prompt_tokens_per_second') }));
state.charts.prompt.setData(series);
DashCharts.renderLegend($('#legend-prompt'), series, (v) => num(v, 0) + ' tok/s', true);
}
if (queue) {
const series = [];
for (const engine of engineNames) {
series.push({ name: `${engine} running`, points: pointsFor(queue, engine, 'requests_running') });
series.push({ name: `${engine} waiting`, points: pointsFor(queue, engine, 'requests_waiting') });
}
state.charts.queue.setData(series);
DashCharts.renderLegend($('#legend-queue'), series, (v) => num(v, 0));
const kv = engineNames.map((engine) => ({ name: engine,
points: pointsFor(queue, engine, 'kv_cache_usage').map((p) => ({ ts: p.ts, value: p.value * 100 })) }));
state.charts.kv.setData(kv);
DashCharts.renderLegend($('#legend-kv'), kv, (v) => num(v, 1) + '%', true);
}
}
function renderFleet(fleet) {
if (!fleet) return;
setTxt($('#f-live'), fleet.engines_live == null ? 'n/a' : String(fleet.engines_live));
setTxt($('#f-total'), fleet.engines_total == null ? '' : `of ${fleet.engines_total}`);
setValue($('#f-decode'), fleet.decode_tokens_per_second, 1);
setValue($('#f-prompt'), fleet.prompt_tokens_per_second, 0);
setTxt($('#f-active'), fleet.requests_active == null ? 'n/a' : num(fleet.requests_active, 0));
setTxt($('#f-waiting'), fleet.requests_waiting == null ? 'n/a' : num(fleet.requests_waiting, 0));
const tokens = humanNumber(fleet.tokens_total);
setTxt($('#f-tokens'), tokens ? tokens.text : 'n/a');
$('#f-tokens').nextElementSibling && setTxt($('#f-tokens').nextElementSibling, tokens ? tokens.unit : '');
const kv = fleet.kv_cache_usage_max == null ? null : fleet.kv_cache_usage_max * 100;
setValue($('#f-kv'), kv, 0);
setBar($('#f-kv').closest('.metric').querySelector('.bar'), kv, severityFor(kv, 75, 90));
$('#seam-note').textContent = fleet.seams || '';
}
async function refresh() {
let snapshot;
try {
snapshot = await fetchJSON('/api/llm/snapshot');
} catch (err) {
$('#sub').textContent = err.message;
return;
}
const rows = {};
(snapshot.states || []).forEach((row) => { rows[row.label.toLowerCase()] = row; });
const payloads = snapshot.engines || [];
const names = payloads.map((p) => p.engine);
payloads.forEach((payload) => {
let card = state.panels.get(payload.engine);
if (!card) {
card = enginePanel(payload.engine);
state.panels.set(payload.engine, card);
$('#engines').appendChild(card);
}
renderPanel(card, payload, rows[payload.engine] ||
(snapshot.states || []).find((row) => row.key.endsWith(payload.engine)));
});
$('#engines-empty').hidden = payloads.length > 0;
renderFleet(snapshot.fleet);
renderSlots(payloads);
const fleet = snapshot.fleet || {};
$('#sub').textContent = `${fleet.engines_live || 0}/${fleet.engines_total || 0} engines · ` +
`${fleet.requests_active == null ? 'n/a' : num(fleet.requests_active, 0)} active · ` +
`window ${duration(state.range)}`;
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · rates over the ` +
`Δ window in each tag`;
await refreshCharts(names);
}
async function refreshEvents() {
try {
const data = await fetchJSON('/api/events?limit=20&source=llm');
const list = $('#events');
if (!(data.events || []).length) {
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">No engine transitions yet.</li>';
return;
}
list.innerHTML = data.events.map((event) => `<li><time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(event.message)}</span></li>`).join('');
} catch (err) { /* next poll */ }
}
document.addEventListener('DOMContentLoaded', async () => {
makeCharts();
try {
const config = await fetchJSON('/api/config');
state.pollMs = config.llm.poll_ms || 2000;
const banners = [];
if (!config.llm.gufo.enabled && !config.llm.vllm.enabled) {
banners.push(['warn', 'Neither engine is configured.']);
}
if (config.ups.simulated) banners.push(['warn', 'UPS numbers on other pages are simulated.']);
$('#banners').innerHTML = banners.map(([kind, html]) => `<div class="banner ${kind}">${html}</div>`)
.join('');
} catch (err) { /* not fatal */ }
Dash.rangeControl($('#range'), state.range, (seconds) => {
state.range = seconds;
Object.values(state.charts).forEach((chart) => { chart.opts.xSpan = seconds; });
refresh();
});
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
Dash.bindPause(state.poller);
});
+503
View File
@@ -0,0 +1,503 @@
/* System dashboard page.
*
* Host cards are created once and patched in place (keyed by host name) — rebuilding the
* DOM every 10s kills hover, transitions, and any scroll position inside the tables.
* Charts get their history from /api/system/history: one request per chart, N hosts each.
*/
'use strict';
const { $, $$, fetchJSON, setTxt, setValue, setBar, setDot, bytes, pct, num, duration, poll,
staleness, markStale, severityFor } = Dash;
const state = {
range: 3600,
detailHost: null,
detailMetric: 'cpu.usage_pct',
hosts: new Map(), // name -> card element
charts: {},
poller: null,
pollMs: 10000,
metricList: [],
};
const DETAIL_METRICS = [
['cpu.usage_pct', 'CPU utilisation', 'pct'],
['cpu.user_pct', 'CPU user', 'pct'],
['cpu.system_pct', 'CPU system', 'pct'],
['cpu.iowait_pct', 'CPU iowait', 'pct'],
['cpu.steal_pct', 'CPU steal', 'pct'],
['cpu.load1', 'Load average (1m)', 'num'],
['cpu.load15', 'Load average (15m)', 'num'],
['cpu.freq_mhz', 'CPU frequency', 'mhz'],
['cpu.context_switches_per_s', 'Context switches', 'num'],
['mem.used_pct', 'Memory used', 'pct'],
['mem.used_bytes', 'Memory used', 'bytes'],
['mem.available_bytes', 'Memory available', 'bytes'],
['mem.swap_used_pct', 'Swap used', 'pct'],
['disk.used_pct', 'Filesystem used (worst)', 'pct'],
['disk.read_bytes_per_s', 'Disk read', 'bytes'],
['disk.write_bytes_per_s', 'Disk write', 'bytes'],
['disk.util_pct', 'Disk busy', 'pct'],
['net.rx_bytes_per_s', 'Network in', 'bytes'],
['net.tx_bytes_per_s', 'Network out', 'bytes'],
['temp.cpu_c', 'CPU temperature', 'temp'],
['temp.nvme_max_c', 'NVMe temperature', 'temp'],
['host.uptime_seconds', 'Uptime', 'dur'],
['host.processes', 'Process count', 'num'],
];
const FORMATS = {
pct: { fn: (v) => num(v, 1), unit: '%', axis: (v) => num(v, 0) },
bytes: { fn: (v) => bytes(v, 1), unit: 'B/s', axis: (v) => (Dash.fmtBytes(v, 0) || { text: '0' }).text +
((Dash.fmtBytes(v, 0) || { unit: '' }).unit) },
mhz: { fn: (v) => num(v, 0) + ' MHz', unit: 'MHz', axis: (v) => num(v, 0) },
temp: { fn: (v) => num(v, 1) + ' °C', unit: '°C', axis: (v) => num(v, 0) },
dur: { fn: (v) => duration(v), unit: '', axis: (v) => duration(v) },
num: { fn: (v) => num(v, 1), unit: '', axis: (v) => num(v, 0) },
};
function formatFor(kind) { return FORMATS[kind] || FORMATS.num; }
function metricKind(name) {
if (name.endsWith('_pct')) return 'pct';
if (name.endsWith('_bytes') || name.includes('bytes_per_s')) return 'bytes';
if (name.includes('_c') && name.startsWith('temp.')) return 'temp';
if (name === 'host.uptime_seconds') return 'dur';
return 'num';
}
// ---- host cards -----------------------------------------------------------
function hostCard(name) {
const card = document.createElement('article');
card.className = 'card';
card.dataset.host = name;
card.innerHTML = `
<header>
<span class="dot idle"></span>
<h3></h3>
<span class="chip os" hidden></span>
<span class="spacer"></span>
<span class="faint mono updated"></span>
</header>
<div class="body stack">
<div class="metrics">
<div class="metric"><div class="label">CPU</div><div class="value"><span data-k="cpu">n/a</span><span class="unit">%</span></div>
<div class="bar" data-bar="cpu"><i></i></div></div>
<div class="metric"><div class="label">Load 1 / 15</div><div class="value sm" data-k="load">n/a</div></div>
<div class="metric"><div class="label">CPU temp</div><div class="value sm"><span data-k="temp">n/a</span><span class="unit">°C</span></div></div>
<div class="metric"><div class="label">Clock</div><div class="value sm"><span data-k="freq">n/a</span><span class="unit">GHz</span></div></div>
<div class="metric"><div class="label">Memory</div><div class="value sm"><span data-k="mem">n/a</span><span class="unit"></span></div>
<div class="bar" data-bar="mem"><i></i></div></div>
<div class="metric"><div class="label">Worst fs</div><div class="value sm"><span data-k="disk">n/a</span><span class="unit"></span></div>
<div class="bar" data-bar="disk"><i></i></div></div>
</div>
<div>
<div class="label faint" style="font-size:10.5px;letter-spacing:.06em;text-transform:uppercase">Per-core</div>
<div class="cores" data-k="cores" style="margin-top:.25rem"></div>
</div>
<div class="metrics">
<div class="metric"><div class="label">Disk read</div><div class="value sm"><span data-k="dr">n/a</span><span class="unit"></span></div></div>
<div class="metric"><div class="label">Disk write</div><div class="value sm"><span data-k="dw">n/a</span><span class="unit"></span></div></div>
<div class="metric"><div class="label">Disk busy</div><div class="value sm"><span data-k="dutil">n/a</span><span class="unit">%</span></div></div>
<div class="metric"><div class="label">Net in</div><div class="value sm"><span data-k="rx">n/a</span><span class="unit"></span></div></div>
<div class="metric"><div class="label">Net out</div><div class="value sm"><span data-k="tx">n/a</span><span class="unit"></span></div></div>
<div class="metric"><div class="label">Up</div><div class="value sm" data-k="uptime">n/a</div></div>
</div>
<div data-k="gpus"></div>
<details>
<summary class="faint" style="cursor:pointer;font-size:12px">Filesystems &amp; processes</summary>
<div style="margin-top:.6rem" data-k="details"></div>
</details>
</div>`;
return card;
}
function renderHostCard(card, host) {
const q = (key) => card.querySelector(`[data-k="${key}"]`);
const stateRow = host.state || {};
card.querySelector('h3').textContent = host.name;
const osChip = card.querySelector('.os');
const osName = (host.info || {}).os_pretty || (host.info || {}).os_id;
if (osName) { osChip.hidden = false; osChip.textContent = osName; }
setDot(card.querySelector('.dot'), stateRow.ok === false ? 'fail'
: (Dash.ageSeconds(stateRow.last_ok) > 3 * (host.window || 10) ? 'warn' : 'ok'));
setTxt(card.querySelector('.updated'), stalenessText(stateRow));
markStale(card, stateRow.ok === false || Dash.ageSeconds(stateRow.last_ok) > 5 * (host.window || 10));
const cpu = host.cpu || {};
setValue(q('cpu'), cpu.usage_pct, 0);
setBar(card.querySelector('[data-bar="cpu"]'), cpu.usage_pct, severityFor(cpu.usage_pct, 75, 90));
setTxt(q('load'), cpu.load1 == null ? 'n/a' : `${num(cpu.load1, 2)} / ${num(cpu.load15, 2)}`);
setValue(q('temp'), cpu.temp_c, 1);
setValue(q('freq'), cpu.freq_mhz == null ? null : cpu.freq_mhz / 1000, 2);
const mem = host.memory || {};
const memText = Dash.fmtBytes(mem.used_bytes, 1);
const memTotal = Dash.fmtBytes(mem.total_bytes, 0);
setTxt(q('mem'), memText ? memText.text : 'n/a');
setTxt(q('mem').nextElementSibling, memText ? `${memText.unit}/${memTotal ? memTotal.text + memTotal.unit : ''}` : '');
setBar(card.querySelector('[data-bar="mem"]'), mem.used_pct, severityFor(mem.used_pct, 80, 92));
const disks = host.disks || {};
setTxt(q('disk'), disks.worst_used_pct == null ? 'n/a' : num(disks.worst_used_pct, 0) + '%');
setTxt(q('disk').nextElementSibling, disks.worst_mount ? ' ' + disks.worst_mount : '');
setBar(card.querySelector('[data-bar="disk"]'), disks.worst_used_pct, severityFor(disks.worst_used_pct, 80, 92));
DashCharts.renderCores(q('cores'), cpu.core_usage_pct || []);
setValue(q('dr'), disks.read_bytes_per_s == null ? null : disks.read_bytes_per_s, null);
if (disks.read_bytes_per_s != null) {
const parts = Dash.fmtBytes(disks.read_bytes_per_s, 1);
setTxt(q('dr'), parts.text); setTxt(q('dr').nextElementSibling, parts.unit + '/s');
const w = Dash.fmtBytes(disks.write_bytes_per_s, 1);
setTxt(q('dw'), w ? w.text : 'n/a'); setTxt(q('dw').nextElementSibling, w ? w.unit + '/s' : '');
} else { setTxt(q('dr'), 'n/a'); setTxt(q('dw'), 'n/a'); }
setValue(q('dutil'), disks.util_pct, 0);
const rx = Dash.fmtBytes((host.network || {}).rx_bytes_per_s, 1);
const tx = Dash.fmtBytes((host.network || {}).tx_bytes_per_s, 1);
setTxt(q('rx'), rx ? rx.text : 'n/a'); setTxt(q('rx').nextElementSibling, rx ? rx.unit + '/s' : '');
setTxt(q('tx'), tx ? tx.text : 'n/a'); setTxt(q('tx').nextElementSibling, tx ? tx.unit + '/s' : '');
setTxt(q('uptime'), duration((host.info || {}).uptime_seconds));
// GPUs: one row each, with both memory pools named (an APU's model lives in GTT)
const gpus = host.gpus || [];
const gpuHost = q('gpus');
if (!gpus.length) {
gpuHost.innerHTML = '<div class="faint" style="font-size:12px">No GPU reported by this agent</div>';
} else {
gpuHost.innerHTML = gpus.map((gpu) => `
<div class="stack" style="gap:.2rem;margin-top:.35rem">
<div class="gap" style="justify-content:space-between">
<span class="mono" style="font-size:12px">${DashCharts.escapeHtml(gpu.model || 'GPU')}</span>
<span class="mono" style="font-size:12px">
${gpu.busy_pct == null ? 'n/a' : num(gpu.busy_pct, 0) + '% busy'} ·
${gpu.power_watts == null ? 'power n/a' : num(gpu.power_watts, 0) + ' W'} ·
${gpu.temp_c == null ? 'temp n/a' : num(gpu.temp_c, 0) + '°C'}</span>
</div>
<div class="bar thin"><i style="width:${(gpu.busy_pct || 0).toFixed(1)}%"></i></div>
<div class="gap" style="justify-content:space-between;font-size:11.5px" class="faint">
<span class="faint">VRAM ${gpu.vram_used_bytes == null ? 'n/a' : bytes(gpu.vram_used_bytes, 0)}
${gpu.vram_total_bytes ? 'of ' + bytes(gpu.vram_total_bytes, 0) : ''}</span>
<span class="faint">shared (GTT) ${gpu.gtt_used_bytes == null ? 'n/a' : bytes(gpu.gtt_used_bytes, 0)}
${gpu.mem_used_pct == null ? '' : ' · pool ' + num(gpu.mem_used_pct, 0) + '%'}</span>
</div>
<div class="bar thin"><i style="width:${(gpu.mem_used_pct || 0).toFixed(1)}%;background:var(--series-2)"></i></div>
</div>`).join('');
}
const filesystems = (disks.filesystems || []).filter((fs) => fs.used_pct != null);
const procs = host.processes || {};
const details = q('details');
details.innerHTML = `
<table class="data">
<thead><tr><th>Mount</th><th>Type</th><th>Used</th><th>Free</th><th>Total</th></tr></thead>
<tbody>${filesystems.map((fs) => `<tr>
<td class="name mono" style="font-size:11.5px">${DashCharts.escapeHtml(fs.mountpoint)}</td>
<td class="mute">${DashCharts.escapeHtml(fs.fstype)}</td>
<td class="num">${num(fs.used_pct, 1)}%</td>
<td class="num mute">${bytes(fs.free_bytes, 0)}</td>
<td class="num mute">${bytes(fs.total_bytes, 0)}</td></tr>`).join('') ||
'<tr><td colspan="5" class="mute">no filesystems reported</td></tr>'}</tbody>
</table>
<div class="grid cols-2" style="margin-top:.6rem">
<div><div class="faint" style="font-size:11px;margin-bottom:.2rem">Top by CPU</div>
<table class="data"><tbody>${(procs.top_cpu || []).slice(0, 5).map((p) => `<tr>
<td class="mono" style="font-size:11.5px">${DashCharts.escapeHtml(p.name)}</td>
<td class="num">${num(p.cpu_pct, 0)}%</td><td class="num mute">pid ${p.pid}</td></tr>`).join('')
|| '<tr><td class="mute">needs two samples</td></tr>'}</tbody></table></div>
<div><div class="faint" style="font-size:11px;margin-bottom:.2rem">Top by RSS</div>
<table class="data"><tbody>${(procs.top_rss || []).slice(0, 5).map((p) => `<tr>
<td class="mono" style="font-size:11.5px">${DashCharts.escapeHtml(p.name)}</td>
<td class="num">${bytes(p.rss_bytes, 0)}</td><td class="num mute">pid ${p.pid}</td></tr>`).join('')
|| '<tr><td class="mute">—</td></tr>'}</tbody></table></div>
</div>
${(host.agent && host.agent.errors && host.agent.errors.length)
? `<div class="banner warn" style="margin-top:.6rem">agent degraded: ${DashCharts.escapeHtml(host.agent.errors.join('; '))}</div>`
: ''}
${(host.degraded && host.degraded.length)
? `<div class="banner warn" style="margin-top:.4rem">collector degraded: ${DashCharts.escapeHtml(host.degraded.join('; '))}</div>`
: ''}`;
}
function stalenessText(row) {
if (!row || !row.configured) return 'not configured';
if (row.last_ok == null) return row.error || 'never reached';
const seconds = Dash.ageSeconds(row.last_ok);
return seconds > 30 ? `stale ${duration(seconds)}` : `updated ${seconds < 2 ? 'now' : duration(seconds)}`;
}
// ---- charts ---------------------------------------------------------------
function makeCharts() {
const common = { height: 165, xSpan: state.range };
state.charts.cpu = new DashCharts.LineChart('#chart-cpu', Object.assign({}, common, {
yMin: 0, yMax: 100, yFormat: (v) => num(v, 0) }));
state.charts.mem = new DashCharts.LineChart('#chart-mem', Object.assign({}, common, {
yMin: 0, yMax: 100, yFormat: (v) => num(v, 0) }));
state.charts.disk = new DashCharts.LineChart('#chart-disk', Object.assign({}, common, {
yMin: 0, yFormat: (v) => (Dash.fmtBytes(v, 0) || { text: '0' }).text }));
state.charts.net = new DashCharts.LineChart('#chart-net', Object.assign({}, common, {
yMin: 0, yFormat: (v) => (Dash.fmtBytes(v, 0) || { text: '0' }).text }));
state.charts.gpu = new DashCharts.LineChart('#chart-gpu', Object.assign({}, common, {
yMin: 0, yMax: 100, yFormat: (v) => num(v, 0) }));
state.charts.temp = new DashCharts.LineChart('#chart-temp', Object.assign({}, common, {
yFormat: (v) => num(v, 0) }));
state.charts.detail = new DashCharts.LineChart('#chart-detail', Object.assign({}, common, {
height: 200, yFormat: (v) => num(v, 1) }));
}
function setChartSpan(span) {
Object.values(state.charts).forEach((chart) => { chart.opts.xSpan = span; });
}
function seriesFor(response, metric) {
return (response.series || []).filter((s) => s.metric === metric).map((s) => ({
name: s.host, points: s.points.map((p) => ({ ts: p.ts, value: p.value })),
}));
}
async function refreshCharts(snapshot) {
const hostNames = (snapshot.hosts || []).map((h) => h.name);
const detailHost = state.detailHost || hostNames[0] || null;
const span = state.range;
const requests = {
cpu: fetchJSON(`/api/system/history?metrics=cpu.usage_pct&range=${span}`).catch(() => null),
mem: fetchJSON(`/api/system/history?metrics=mem.used_pct&range=${span}`).catch(() => null),
temp: fetchJSON(`/api/system/history?metrics=temp.cpu_c&range=${span}`).catch(() => null),
disk: detailHost ? fetchJSON(`/api/system/history?hosts=${encodeURIComponent(detailHost)}&` +
`metrics=disk.read_bytes_per_s,disk.write_bytes_per_s&range=${span}`).catch(() => null) : null,
net: detailHost ? fetchJSON(`/api/system/history?hosts=${encodeURIComponent(detailHost)}&` +
`metrics=net.rx_bytes_per_s,net.tx_bytes_per_s&range=${span}`).catch(() => null) : null,
};
const [cpu, mem, temp, disk, net] = await Promise.all([requests.cpu, requests.mem,
requests.temp, requests.disk, requests.net]);
if (cpu) {
const series = seriesFor(cpu, 'cpu.usage_pct');
state.charts.cpu.setData(series);
DashCharts.renderLegend($('#legend-cpu'), series, (v) => num(v, 1) + '%', true);
}
if (mem) {
const series = seriesFor(mem, 'mem.used_pct');
state.charts.mem.setData(series);
DashCharts.renderLegend($('#legend-mem'), series, (v) => num(v, 1) + '%');
}
if (temp) {
const series = seriesFor(temp, 'temp.cpu_c');
state.charts.temp.setData(series);
DashCharts.renderLegend($('#legend-temp'), series, (v) => num(v, 1) + '°C', true);
}
if (disk) {
const read = (disk.series.find((s) => s.metric === 'disk.read_bytes_per_s') || { points: [] })
.points.map((p) => ({ ts: p.ts, value: p.value }));
const write = (disk.series.find((s) => s.metric === 'disk.write_bytes_per_s') || { points: [] })
.points.map((p) => ({ ts: p.ts, value: p.value }));
const series = [{ name: 'read', points: read }, { name: 'write', points: write }];
state.charts.disk.setData(series);
DashCharts.renderLegend($('#legend-disk'), series, (v) => bytes(v, 1) + '/s', true);
}
if (net) {
const rx = (net.series.find((s) => s.metric === 'net.rx_bytes_per_s') || { points: [] })
.points.map((p) => ({ ts: p.ts, value: p.value }));
const tx = (net.series.find((s) => s.metric === 'net.tx_bytes_per_s') || { points: [] })
.points.map((p) => ({ ts: p.ts, value: p.value }));
const series = [{ name: 'in', points: rx }, { name: 'out', points: tx }];
state.charts.net.setData(series);
DashCharts.renderLegend($('#legend-net'), series, (v) => bytes(v, 1) + '/s', true);
}
await refreshGpuChart(hostNames);
await refreshDetailChart();
}
async function refreshGpuChart(hostNames) {
if (!hostNames.length) {
state.charts.gpu.setData([]);
$('#legend-gpu').innerHTML = '<span class="faint">no hosts</span>';
return;
}
const series = [];
for (const host of hostNames) {
// per-stream names are discovered, not guessed: gpu.busy_pct@0, @1, ...
const found = await fetchJSON(`/api/metrics?source=host&host=${encodeURIComponent(host)}`)
.catch(() => ({ metrics: [] }));
const names = (found.metrics || []).filter((m) => m.startsWith('gpu.busy_pct@'));
for (const name of names) {
const data = await fetchJSON(`/api/history?source=host&host=${encodeURIComponent(host)}` +
`&metric=${encodeURIComponent(name)}&range=${state.range}`).catch(() => null);
if (data) series.push({ name: `${host} ${name.split('@')[1]}`,
points: data.points.map((p) => ({ ts: p.ts, value: p.value })) });
}
}
state.charts.gpu.setData(series);
DashCharts.renderLegend($('#legend-gpu'), series, (v) => num(v, 0) + '%', true);
}
async function refreshDetailChart() {
const host = state.detailHost;
if (!host) { state.charts.detail.setData([]); return; }
const kind = metricKind(state.detailMetric);
const format = formatFor(kind);
const data = await fetchJSON(`/api/history?source=host&host=${encodeURIComponent(host)}` +
`&metric=${encodeURIComponent(state.detailMetric)}&range=${state.range}`).catch(() => null);
if (!data) return;
const series = [{ name: state.detailMetric,
points: data.points.map((p) => ({ ts: p.ts, value: p.value })) }];
state.charts.detail.opts.yFormat = format.axis;
state.charts.detail.opts.yMin = state.detailMetric.endsWith('_pct') ? 0 : null;
state.charts.detail.opts.yMax = state.detailMetric.endsWith('_pct') ? 100 : null;
state.charts.detail.setData(series);
DashCharts.renderLegend($('#legend-detail'), series, format.fn);
const last = series[0].points.slice(-1)[0];
$('#detail-note').textContent = `${series[0].points.length} samples over ${duration(state.range)} · ` +
`last ${last ? format.fn(last.value) : 'n/a'} · ${state.range <= 86400 ? 'raw resolution' : 'rolled up'}`;
}
async function populateDetailMetrics(host) {
const select = $('#detail-metric');
const found = await fetchJSON(`/api/metrics?source=host&host=${encodeURIComponent(host)}`)
.catch(() => ({ metrics: [] }));
const curated = new Set(DETAIL_METRICS.map(([name]) => name));
const extras = (found.metrics || []).filter((m) => !curated.has(m)).sort();
select.innerHTML = '';
const groupMain = document.createElement('optgroup');
groupMain.label = 'Common';
DETAIL_METRICS.forEach(([name, label]) => {
const option = document.createElement('option');
option.value = name; option.textContent = label;
groupMain.appendChild(option);
});
select.appendChild(groupMain);
if (extras.length) {
const groupExtra = document.createElement('optgroup');
groupExtra.label = `Per-device (${extras.length})`;
extras.forEach((name) => {
const option = document.createElement('option');
option.value = name; option.textContent = name;
groupExtra.appendChild(option);
});
select.appendChild(groupExtra);
}
select.value = DETAIL_METRICS.some(([name]) => name === state.detailMetric)
? state.detailMetric : (extras[0] || DETAIL_METRICS[0][0]);
state.detailMetric = select.value || DETAIL_METRICS[0][0];
}
// ---- snapshot -------------------------------------------------------------
function renderFleet(fleet) {
if (!fleet) return;
setTxt($('#f-hosts'), fleet.hosts_live == null ? 'n/a' : String(fleet.hosts_live));
setTxt($('#f-hosts-of'), fleet.hosts_total == null ? '' : `of ${fleet.hosts_total}`);
setValue($('#f-cpu'), fleet.cpu_usage_pct_avg, 0);
setValue($('#f-load'), fleet.load1_total, 2);
const used = Dash.fmtBytes(fleet.memory_used_bytes, 0);
const total = Dash.fmtBytes(fleet.memory_total_bytes, 0);
setTxt($('#f-mem'), used ? used.text : 'n/a');
setTxt($('#f-mem-of'), used && total ? `${used.unit} of ${total.text} ${total.unit}` : '');
setValue($('#f-disk'), fleet.disk_worst_used_pct, 0);
const rx = Dash.fmtBytes(fleet.net_rx_bytes_per_s, 1);
const tx = Dash.fmtBytes(fleet.net_tx_bytes_per_s, 1);
setTxt($('#f-rx'), rx ? rx.text : 'n/a'); setTxt($('#f-rx-unit'), rx ? rx.unit + '/s' : '');
setTxt($('#f-tx'), tx ? tx.text : 'n/a'); setTxt($('#f-tx-unit'), tx ? tx.unit + '/s' : '');
setValue($('#f-gpu'), fleet.gpu_busy_pct_avg, 0);
}
async function refresh() {
let snapshot;
try {
snapshot = await fetchJSON('/api/system/snapshot');
} catch (err) {
$('#sub').textContent = err.message;
return;
}
const states = {};
(snapshot.states || []).forEach((row) => { states[row.key.split(':')[1]] = row; });
const hostNames = [];
(snapshot.hosts || []).forEach((host) => {
hostNames.push(host.name);
host.state = states[host.name];
host.window = snapshot.window_seconds;
let card = state.hosts.get(host.name);
if (!card) {
card = hostCard(host.name);
state.hosts.set(host.name, card);
$('#hosts').appendChild(card);
card.addEventListener('click', () => {
if (state.detailHost !== host.name) {
state.detailHost = host.name;
$('#detail-host').value = host.name;
populateDetailMetrics(host.name).then(refreshDetailChart);
}
});
}
renderHostCard(card, host);
});
$('#hosts-empty').hidden = hostNames.length > 0;
const hostSelect = $('#detail-host');
if (hostSelect.dataset.names !== hostNames.join(',')) {
hostSelect.dataset.names = hostNames.join(',');
hostSelect.innerHTML = hostNames.map((name) =>
`<option value="${DashCharts.escapeHtml(name)}">${DashCharts.escapeHtml(name)}</option>`).join('');
if (!state.detailHost || !hostNames.includes(state.detailHost)) {
state.detailHost = hostNames[0] || null;
}
if (state.detailHost) hostSelect.value = state.detailHost;
if (state.detailHost) await populateDetailMetrics(state.detailHost);
}
renderFleet(snapshot.fleet);
const fleet = snapshot.fleet || {};
$('#sub').textContent = `${fleet.hosts_live || 0}/${fleet.hosts_total || 0} hosts answering · ` +
`${fleet.cpu_cores || 0} cores · ${fleet.gpu_count || 0} GPUs · ` +
`window ${duration(state.range)}`;
$('#foot-note').textContent = `rates over Δ${snapshot.window_seconds}s · ` +
`poll ${Math.round(state.pollMs / 1000)}s`;
await refreshCharts(snapshot);
}
async function refreshEvents() {
try {
const data = await fetchJSON('/api/events?limit=25&source=host');
const list = $('#events');
if (!(data.events || []).length) {
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">No host transitions ' +
'recorded yet — this log fills when a host goes away, comes back, or reboots.</li>';
return;
}
list.innerHTML = data.events.map((event) => `<li><time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(event.message)}</span></li>`).join('');
} catch (err) { /* the next poll will try again */ }
}
document.addEventListener('DOMContentLoaded', async () => {
makeCharts();
let pollMs = 10000;
try {
const config = await fetchJSON('/api/config');
pollMs = config.system.poll_ms || 10000;
if (!config.system.enabled) $('#sub').textContent = config.system.target_problems.length
? `bad HOST_TARGETS: ${config.system.target_problems.join('; ')}`
: 'no hosts configured — set HOST_TARGETS';
} catch (err) { /* config is nice-to-have */ }
state.pollMs = pollMs;
Dash.rangeControl($('#range'), state.range, (seconds) => {
state.range = seconds;
setChartSpan(seconds);
refresh();
});
$('#detail-host').addEventListener('change', async (event) => {
state.detailHost = event.target.value;
await populateDetailMetrics(state.detailHost);
await refreshDetailChart();
});
$('#detail-metric').addEventListener('change', async (event) => {
state.detailMetric = event.target.value;
await refreshDetailChart();
});
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
Dash.bindPause(state.poller);
});
+358
View File
@@ -0,0 +1,358 @@
/* UPS dashboard: health assessment, NUT instant commands, history.
*
* The health list is deliberately explicit checks rather than a score: every row names the
* variable it read and what it saw, because "health: 87" is not actionable. The backend
* owns that logic (sources/nut.py::assess) so the same rules apply in tests and API clients.
*
* Commands are the only write path in the product. They are offered only when the backend
* says `enabled`, and anything that can cut power requires typing DANGER here *and* the
* server re-checks the same gate — a hidden button is not a safety control.
*/
'use strict';
const { $, fetchJSON, setTxt, setValue, setBar, setDot, num, duration, poll, severityFor } = Dash;
const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000,
simulated: false, pending: null };
const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' };
/**
* Runtime left as "1h 32m", not "1.5h". The health check next to it spells the same number
* out in h+m; two formats for one value invites a misread while the UPS is discharging.
*/
function runtimeText(seconds) {
if (seconds == null || !isFinite(seconds)) return 'n/a';
if (seconds < 0) return 'on battery';
const total = Math.round(seconds);
if (total < 120) return `${total}s`;
const minutes = Math.round(total / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
}
function upsCard(name) {
const card = document.createElement('article');
card.className = 'card';
card.dataset.ups = name;
card.innerHTML = `
<header>
<span class="dot idle"></span>
<h3></h3>
<span class="chip model" hidden></span>
<span class="sim-badge" hidden>simulated</span>
<span class="spacer"></span>
<span class="chip health">unknown</span>
<span class="faint mono updated"></span>
</header>
<div class="body stack">
<div class="gap states"></div>
<div class="metrics">
<div class="metric"><div class="label">Charge</div>
<div class="value"><span data-k="charge">n/a</span><span class="unit">%</span></div>
<div class="bar" data-bar="charge"><i></i></div></div>
<div class="metric"><div class="label">Runtime left</div>
<div class="value sm" data-k="runtime">n/a</div></div>
<div class="metric"><div class="label">Load</div>
<div class="value sm"><span data-k="load">n/a</span><span class="unit">%</span></div>
<div class="bar" data-bar="load"><i></i></div></div>
<div class="metric"><div class="label">Line in</div>
<div class="value sm"><span data-k="line">n/a</span><span class="unit">V</span></div></div>
<div class="metric"><div class="label">Out</div>
<div class="value sm"><span data-k="out">n/a</span><span class="unit">V</span></div></div>
<div class="metric"><div class="label">Battery</div>
<div class="value sm"><span data-k="batt">n/a</span><span class="unit">V</span></div></div>
<div class="metric"><div class="label">Battery A</div>
<div class="value sm"><span data-k="amps">n/a</span><span class="unit">A</span></div></div>
<div class="metric"><div class="label">Battery °C</div>
<div class="value sm"><span data-k="temp">n/a</span><span class="unit">°C</span></div></div>
</div>
<div>
<div class="faint" style="font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;margin-bottom:.3rem">
Health checks</div>
<ul class="checks" data-k="checks"></ul>
</div>
<div data-k="commands"></div>
<details>
<summary class="faint" style="cursor:pointer;font-size:12px">NUT variables</summary>
<div class="body tight" data-k="raw" style="padding:0;margin-top:.5rem"></div>
</details>
</div>`;
return card;
}
function renderCard(card, item, stateRow) {
const q = (key) => card.querySelector(`[data-k="${key}"]`);
card.querySelector('h3').textContent = item.name;
const model = [item.identity && item.identity.mfr, item.identity && item.identity.model]
.filter(Boolean).join(' ');
const modelChip = card.querySelector('.model');
if (model) { modelChip.hidden = false; modelChip.textContent = model; }
card.querySelector('.sim-badge').hidden = !state.simulated;
const overall = (item.health && item.health.overall) || 'unknown';
const healthChip = card.querySelector('.health');
healthChip.textContent = overall;
healthChip.className = 'chip health ' + ({ healthy: 'ok', degraded: 'warn', 'at risk': 'fail',
unknown: '' }[overall] || '');
const severity = stateRow && !stateRow.ok ? 'fail'
: (item.health && item.health.severity) || 'idle';
setDot(card.querySelector('.dot'), severity === 'ok' ? 'ok' : severity === 'warn' ? 'warn'
: severity === 'fail' ? 'fail' : 'idle');
setTxt(card.querySelector('.updated'), stateRow && stateRow.last_ok != null
? (Dash.ageSeconds(stateRow.last_ok) > 30 ? `stale ${duration(Dash.ageSeconds(stateRow.last_ok))}`
: `updated ${duration(Dash.ageSeconds(stateRow.last_ok))}`)
: (stateRow && stateRow.error) || 'never reached');
const states = card.querySelector('.states');
states.innerHTML = (item.states || []).map((s) => {
const kind = s.severity === 'ok' ? 'ok' : s.severity === 'warn' ? 'warn'
: s.severity === 'fail' ? 'fail' : 'info';
return `<span class="chip ${kind}" title="${DashCharts.escapeHtml(s.detail || s.label)}">${
DashCharts.escapeHtml(s.label)}</span>`;
}).join('') + (item.status ? `<span class="chip mono" title="raw ups.status">${
DashCharts.escapeHtml(item.status)}</span>` : '') +
(item.alarms || []).map((alarm) => `<span class="chip warn">alarm: ${
DashCharts.escapeHtml(alarm)}</span>`).join('');
const n = item.numbers || {};
const cell = (key) => q(key);
setValue(cell('charge'), n.charge_pct, 0);
setBar(card.querySelector('[data-bar="charge"]'), n.charge_pct,
n.charge_pct == null ? '' : n.charge_pct >= 95 ? 'ok' : n.charge_pct >= 60 ? 'warn' : 'fail');
setTxt(cell('runtime'), runtimeText(n.battery_runtime));
setValue(cell('load'), n.load_pct, 1);
setBar(card.querySelector('[data-bar="load"]'), n.load_pct, severityFor(n.load_pct, 70, 90));
setValue(cell('line'), n.line_voltage, 1);
setValue(cell('out'), n.output_voltage, 1);
setValue(cell('batt'), n.battery_voltage, 2);
setValue(cell('amps'), n.battery_current, 2);
setValue(cell('temp'), n.battery_temperature, 1);
const health = item.health || { checks: [] };
q('checks').innerHTML = (health.checks || []).map((check) => `
<li class="${check.status}">
<span class="mark" title="${check.status}">${MARKS[check.status] || '?'}</span>
<span><span class="what">${DashCharts.escapeHtml(check.name)}</span><br>
<span class="detail">${DashCharts.escapeHtml(check.detail)}</span></span>
</li>`).join('') || '<li class="unknown"><span class="mark">?</span><span>No checks available</span></li>';
const commands = item.commands || [];
const groups = [['safe', 'Diagnostics'], ['caution', 'Affects the load briefly'],
['dangerous', 'Can cut power']];
const host = q('commands');
host.innerHTML = groups.map(([risk, legend]) => {
const inGroup = commands.filter((command) => command.risk === risk);
if (!inGroup.length) return '';
return `<fieldset class="commands" style="margin-top:.5rem"><legend>${legend}</legend>
<div class="row">${inGroup.map((command) => `
<button class="action ${risk}" type="button" data-command="${command.command}"
${command.enabled ? '' : 'disabled'}
title="${DashCharts.escapeHtml(command.enabled ? command.help : command.reason)}">${
DashCharts.escapeHtml(command.label)}</button>`).join('')}</div></fieldset>`;
}).join('') || '<div class="faint" style="font-size:12px;margin-top:.4rem">' +
'The driver advertises no commands we know how to send.</div>';
host.querySelectorAll('button[data-command]').forEach((button) => {
button.addEventListener('click', () => openCommandDialog(item.name, button.dataset.command,
commands));
});
const extra = item.extra || {};
const entries = Object.entries(extra);
q('raw').innerHTML = `<table class="data"><tbody>${entries.map(([key, value]) =>
`<tr><td class="mono" style="font-size:11px">${DashCharts.escapeHtml(key)}</td>
<td class="mono" style="font-size:11px">${DashCharts.escapeHtml(value)}</td></tr>`).join('')
|| '<tr><td class="mute">no extra variables</td></tr>'}</tbody></table>` +
`<div class="faint mono" style="font-size:11px;margin-top:.4rem">driver ${
DashCharts.escapeHtml((item.identity || {}).driver || '?')} · firmware ${
DashCharts.escapeHtml((item.identity || {}).firmware || '?')} · serial ${
DashCharts.escapeHtml((item.identity || {}).serial || '?')} · ${
(item.advertised_commands || []).length} commands advertised</div>`;
}
// ---- command dialog -------------------------------------------------------
function openCommandDialog(upsName, commandName, commands) {
const command = commands.find((c) => c.command === commandName);
if (!command) return;
const dialog = $('#confirm-dialog');
const dangerous = command.risk === 'dangerous';
$('#dialog-title').textContent = command.label;
$('#dialog-help').textContent = command.help;
$('#dialog-target').textContent = `UPS ${upsName} · ${command.command} · risk: ${command.risk}` +
(state.simulated ? ' · this UPS is simulated' : '');
const riskChip = $('#dialog-risk');
riskChip.textContent = command.risk;
riskChip.className = 'chip ' + (command.risk === 'dangerous' ? 'fail'
: command.risk === 'caution' ? 'warn' : 'ok');
$('#dialog-confirm-wrap').hidden = !dangerous;
$('#dialog-input-wrap').hidden = !dangerous;
$('#dialog-input').value = '';
const run = $('#dialog-run');
run.classList.toggle('dangerous', dangerous);
run.disabled = dangerous;
state.pending = { ups: upsName, command: commandName, dangerous };
dialog.showModal();
if (dangerous) $('#dialog-input').focus();
}
async function runPending() {
const pending = state.pending;
if (!pending) return;
const body = { ups: pending.ups, command: pending.command };
if (pending.dangerous) body.confirm = $('#dialog-input').value.trim();
$('#confirm-dialog').close();
try {
const result = await fetchJSON('/api/ups/command', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
Dash.toast(`sent ${result.command} to ${result.ups}: ${result.detail}`, 'ok');
} catch (err) {
Dash.toast(`refused: ${err.message}`, 'fail');
}
state.pending = null;
refresh();
}
// ---- charts ---------------------------------------------------------------
function makeCharts() {
const common = { height: 160, xSpan: state.range };
state.charts.charge = new DashCharts.LineChart('#chart-charge', Object.assign({}, common,
{ yMin: 0, yMax: 100, yFormat: (v) => num(v, 0) }));
state.charts.runtime = new DashCharts.LineChart('#chart-runtime', Object.assign({}, common,
{ yMin: 0, yFormat: (v) => duration(v) }));
state.charts.voltage = new DashCharts.LineChart('#chart-voltage', Object.assign({}, common,
{ yFormat: (v) => num(v, 1) }));
state.charts.load = new DashCharts.LineChart('#chart-load', Object.assign({}, common,
{ yMin: 0, yFormat: (v) => num(v, 0) }));
}
function points(response, upsName, metric) {
const found = (response.series || []).find((s) => s.host === upsName && s.metric === metric);
return found ? found.points.map((p) => ({ ts: p.ts, value: p.value })) : [];
}
async function refreshCharts(names) {
if (!names.length) return;
const list = names.join(',');
const metrics = 'ups.charge_pct,ups.battery_runtime_seconds,ups.battery_voltage,' +
'ups.line_voltage,ups.load_pct';
const data = await fetchJSON(`/api/ups/history?ups=${encodeURIComponent(list)}&metrics=${metrics}` +
`&range=${state.range}`).catch(() => null);
if (!data) return;
const build = (metric, scale) => names.map((name) => ({
name, points: points(data, name, metric).map((p) => (scale ? { ts: p.ts, value: scale(p.value) } : p)),
}));
const charge = build('ups.charge_pct');
state.charts.charge.setData(charge);
DashCharts.renderLegend($('#legend-charge'), charge, (v) => num(v, 0) + '%');
const runtime = build('ups.battery_runtime_seconds');
state.charts.runtime.setData(runtime);
DashCharts.renderLegend($('#legend-runtime'), runtime, (v) => duration(v), true);
const battery = build('ups.battery_voltage');
const line = build('ups.line_voltage');
const voltage = battery.map((series, index) => ({ name: `${series.name} battery`,
points: series.points })).concat(line.map((series, index) => ({ name: `${series.name} line`,
points: series.points, color: undefined })));
state.charts.voltage.setData(voltage);
DashCharts.renderLegend($('#legend-voltage'), voltage, (v) => num(v, 1) + ' V');
const load = build('ups.load_pct');
state.charts.load.setData(load);
DashCharts.renderLegend($('#legend-load'), load, (v) => num(v, 0) + '%', true);
}
async function refresh() {
let snapshot;
try {
snapshot = await fetchJSON('/api/ups/snapshot');
} catch (err) {
$('#sub').textContent = err.message;
return;
}
state.simulated = !!snapshot.simulated;
const stateRow = (snapshot.states || [])[0];
const items = snapshot.ups || [];
items.forEach((item) => {
let card = state.cards.get(item.name);
if (!card) {
card = upsCard(item.name);
state.cards.set(item.name, card);
$('#ups-cards').appendChild(card);
}
renderCard(card, item, stateRow);
});
$('#ups-empty').hidden = items.length > 0;
const banners = [];
if (state.simulated) {
banners.push(['warn', '<strong>SIMULATED DATA.</strong> <code>NUT_SIMULATE=1</code> — these are ' +
'modelled UPSes for development, not your batteries. Unset it to read real upsd.']);
}
if (!snapshot.actions_enabled) {
banners.push(['info', 'Command buttons are disabled. Set <code>NUT_ALLOW_ACTIONS=1</code> plus ' +
'<code>NUT_USER</code>/<code>NUT_PASSWORD</code> for a user with <code>actions</code> in ' +
'<code>upsd.users</code>.']);
}
if (stateRow && !stateRow.ok && stateRow.error) {
banners.push(['fail', `upsd: ${DashCharts.escapeHtml(stateRow.error)}`]);
}
$('#banners').innerHTML = banners.map(([kind, html]) => `<div class="banner ${kind}">${html}</div>`)
.join('');
// "0 healthy" reads like an outage when every UPS is merely past its replacement window;
// name the states that are actually present instead of counting the absence of one.
const counts = { healthy: 0, degraded: 0, 'at risk': 0, unknown: 0 };
items.forEach((item) => { counts[(item.health && item.health.overall) || 'unknown'] += 1; });
const breakdown = Object.entries(counts).filter(([, n]) => n > 0)
.map(([name, n]) => `${n} ${name}`).join(', ') || 'none reporting';
$('#sub').textContent = `${items.length} UPS${items.length === 1 ? '' : 'es'} · ${breakdown} · ` +
(snapshot.server ? `upsd ${snapshot.server} · ` : '') + `window ${duration(state.range)}`;
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · ` +
`${items.reduce((total, item) => total + (item.advertised_commands || []).length, 0)} commands advertised`;
await refreshCharts(items.map((item) => item.name));
await refreshEvents();
}
async function refreshEvents() {
try {
const data = await fetchJSON('/api/events?limit=40&source=ups');
const list = $('#events');
if (!(data.events || []).length) {
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">No power events yet — ' +
'state changes, self-test results, and commands you send all land here.</li>';
return;
}
list.innerHTML = data.events.map((event) => `<li><time>${
new Date(event.ts * 1000).toLocaleString(undefined,
{ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(event.message.replace(event.host + ':', '').trim())}` +
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span></li>`).join('');
} catch (err) { /* next poll */ }
}
document.addEventListener('DOMContentLoaded', async () => {
makeCharts();
try {
const config = await fetchJSON('/api/config');
state.pollMs = config.ups.poll_ms || 5000;
} catch (err) { /* default poll rate */ }
Dash.rangeControl($('#range'), state.range, (seconds) => {
state.range = seconds;
Object.values(state.charts).forEach((chart) => { chart.opts.xSpan = seconds; });
refresh();
}, [['1h', 3600], ['6h', 21600], ['24h', 86400], ['7d', 604800], ['30d', 2592000],
['90d', 7776000]]);
$('#dialog-cancel').addEventListener('click', () => { $('#confirm-dialog').close(); state.pending = null; });
$('#dialog-run').addEventListener('click', runPending);
$('#dialog-input').addEventListener('input', (event) => {
$('#dialog-run').disabled = event.target.value.trim() !== 'DANGER';
});
state.poller = poll(refresh, state.pollMs);
Dash.bindPause(state.poller);
});
+71
View File
@@ -0,0 +1,71 @@
"""Shared fixtures: real captures for shape, fakes for behaviour.
Anything that must match an upstream (an agent snapshot, an engine's /metrics text, the NUT
wire protocol) is asserted against a verbatim capture in tests/fixtures/. Everything that
needs a *failure* — a dead host, a refused command, a counter reset — is a fake here.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
import pytest
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from dashboards.rates import RateEstimator # noqa: E402
from dashboards.store import SampleStore # noqa: E402
FIXTURES = Path(__file__).resolve().parent / "fixtures"
@pytest.fixture
def db(tmp_path: Path) -> Any:
store = SampleStore(tmp_path / "t.sqlite3")
try:
yield store
finally:
store.close()
@pytest.fixture
def store(db: Any) -> Any:
"""Alias: some tests read better with `store`, others with `db`. Same object."""
return db
@pytest.fixture
def agent_snapshot() -> dict[str, Any]:
"""A real driveripper snapshot (Threadripper + R9700, 48 logical CPUs)."""
return json.loads((FIXTURES / "agent_driveripper.json").read_text())
@pytest.fixture
def agent_snapshots() -> dict[str, dict[str, Any]]:
"""Every captured host, keyed by name — fleet code paths need >1 host to mean anything."""
out: dict[str, dict[str, Any]] = {}
for path in sorted(FIXTURES.glob("agent_*.json")):
out[path.stem.removeprefix("agent_")] = json.loads(path.read_text())
return out
@pytest.fixture
def metrics_text() -> Any:
"""metrics_text('gufo') / metrics_text('vllm') -> the captured exposition verbatim."""
def load(engine: str) -> str:
path = FIXTURES / f"metrics_{engine}.txt"
if not path.exists():
pytest.skip(f"missing fixture {path.name} — run scripts/capture-fixtures.sh --with-llm")
return path.read_text()
return load
@pytest.fixture
def rates() -> RateEstimator:
return RateEstimator(span=10.0)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"ok": true, "schema": 1, "agent_version": "1.0.0", "ts": 1792178443.0656452, "duration_ms": 10.22, "host": {"hostname": "gateway", "os_pretty": "AlmaLinux 10.2 (Lavender Lion)", "os_id": "almalinux", "kernel": "6.12.0-211.65.1.el10_2.x86_64_v2", "arch": "x86_64", "python": "3.12.15", "cpu_model": "AMD Ryzen Threadripper 3960X 24-Core Processor", "cpu_cores_logical": 2, "cpu_cores_physical": 2, "memory_total_bytes": 2051493888.0, "boot_ts": 1791967457.1014743, "uptime_seconds": 210985.95, "container": false, "agent_version": "1.0.0"}, "cpu": {"modes": {"user": 561498.0, "nice": 2756.0, "system": 122750.0, "idle": 41118362.0, "iowait": 290460.0, "irq": 24373.0, "softirq": 46665.0, "steal": 4918.0, "guest": 0.0, "guest_nice": 0.0, "total": 42171782.0}, "cores": [{"core": 0, "modes": {"user": 285993.0, "nice": 1771.0, "system": 60082.0, "idle": 20538975.0, "iowait": 143245.0, "irq": 14642.0, "softirq": 38622.0, "steal": 3640.0, "guest": 0.0, "guest_nice": 0.0, "total": 21086970.0}}, {"core": 1, "modes": {"user": 275504.0, "nice": 985.0, "system": 62667.0, "idle": 20579386.0, "iowait": 147214.0, "irq": 9730.0, "softirq": 8043.0, "steal": 1277.0, "guest": 0.0, "guest_nice": 0.0, "total": 21084806.0}}], "clock_ticks_per_second": 100, "load1": 0.01, "load5": 0.07, "load15": 0.08, "procs_running": 1.0, "procs_blocked": 2.0, "ctxt_total": 64391428.0, "processes_total": 343402.0, "freq_mhz": null, "freq_max_mhz": null}, "memory": {"total_bytes": 2051493888.0, "free_bytes": 172544000.0, "available_bytes": 1400086528.0, "used_bytes": 651407360.0, "buffers_bytes": 106156032.0, "cached_bytes": 1315049472.0, "dirty_bytes": 2158592.0, "swap_total_bytes": 0.0, "swap_used_bytes": 0.0, "committed_bytes": 720392192.0}, "filesystems": [{"device": "/dev/vda4", "mountpoint": "/", "fstype": "ext4", "total_bytes": 9197531136, "used_bytes": 5252460544, "free_bytes": 3455754240, "used_pct": 60.316157493618384, "read_only": false}, {"device": "/dev/vda3", "mountpoint": "/boot", "fstype": "ext4", "total_bytes": 1020702720, "used_bytes": 272543744, "free_bytes": 677695488, "used_pct": 28.68159246870582, "read_only": false}, {"device": "/dev/vda2", "mountpoint": "/boot/efi", "fstype": "vfat", "total_bytes": 209489920, "used_bytes": 9560064, "free_bytes": 199929856, "used_pct": 4.56349594290742, "read_only": false}], "disk_io": {"devices": [{"name": "vda", "model": null, "rotational": true, "size_bytes": 10737418240.0, "reads_total": 14464.0, "writes_total": 1989589.0, "read_bytes_total": 645659136.0, "write_bytes_total": 22551089664.0, "in_flight": 0.0, "io_time_ms_total": 2882080.0, "weighted_io_ms_total": 6688324.0, "discard_bytes_total": 0.0}], "device_names": ["vda"]}, "network": {"interfaces": [{"name": "eth0", "rx_bytes_total": 14580699012.0, "rx_packets_total": 22626423.0, "rx_errors_total": 0.0, "rx_dropped_total": 287.0, "tx_bytes_total": 10807075678.0, "tx_packets_total": 10084825.0, "tx_errors_total": 0.0, "tx_dropped_total": 0.0, "speed_mbps": null}]}, "gpus": [], "temperatures": [], "cpu_temperature_celsius": null, "processes": {"total": 142, "top_rss": [{"pid": 592, "name": "systemd-journal", "rss_bytes": 105857024.0}, {"pid": 1182, "name": "haproxy", "rss_bytes": 99045376.0}, {"pid": 723, "name": "firewalld", "rss_bytes": 45318144.0}, {"pid": 1091, "name": "polkitd", "rss_bytes": 41594880.0}, {"pid": 1, "name": "systemd", "rss_bytes": 37564416.0}], "top_cpu": [{"pid": 342784, "name": "python3", "cpu_pct": 2.680722502695548}, {"pid": 1, "name": "systemd", "cpu_pct": 0.0}, {"pid": 2, "name": "kthreadd", "cpu_pct": 0.0}, {"pid": 3, "name": "pool_workqueue_release", "cpu_pct": 0.0}, {"pid": 4, "name": "kworker/R-rcu_gp", "cpu_pct": 0.0}]}, "errors": []}
+45
View File
@@ -0,0 +1,45 @@
# HELP gufo_device_lost_total Confirmed GPU context losses
# TYPE gufo_device_lost_total counter
gufo_device_lost_total 0
# HELP llamacpp:prompt_tokens_total Total prompt tokens processed, excluding cache hits
# TYPE llamacpp:prompt_tokens_total counter
llamacpp:prompt_tokens_total 1350666
# HELP llamacpp:prompt_tokens_cached_total Total prompt tokens reused from cache
# TYPE llamacpp:prompt_tokens_cached_total counter
llamacpp:prompt_tokens_cached_total 124529105
# HELP llamacpp:prompt_seconds_total Prompt process time
# TYPE llamacpp:prompt_seconds_total counter
llamacpp:prompt_seconds_total 1478.1443332270005
# HELP llamacpp:tokens_predicted_total Total tokens generated
# TYPE llamacpp:tokens_predicted_total counter
llamacpp:tokens_predicted_total 877336
# HELP llamacpp:tokens_predicted_seconds_total Predict process time
# TYPE llamacpp:tokens_predicted_seconds_total counter
llamacpp:tokens_predicted_seconds_total 26565.247833160014
# HELP llamacpp:n_tokens_max Largest observed n_tokens.
# TYPE llamacpp:n_tokens_max counter
llamacpp:n_tokens_max 252622
# HELP llamacpp:spec_decode_num_drafts_total Total speculative verification rounds
# TYPE llamacpp:spec_decode_num_drafts_total counter
llamacpp:spec_decode_num_drafts_total 291738
# HELP llamacpp:spec_decode_num_draft_tokens_total Total draft tokens proposed
# TYPE llamacpp:spec_decode_num_draft_tokens_total counter
llamacpp:spec_decode_num_draft_tokens_total 789028
# HELP llamacpp:spec_decode_num_accepted_tokens_total Total draft tokens accepted
# TYPE llamacpp:spec_decode_num_accepted_tokens_total counter
llamacpp:spec_decode_num_accepted_tokens_total 578514
# HELP llamacpp:prompt_tokens_seconds Prompt processing speed in tokens per second
# TYPE llamacpp:prompt_tokens_seconds gauge
llamacpp:prompt_tokens_seconds 160.47362399350274
# HELP llamacpp:predicted_tokens_seconds Generation speed in tokens per second
# TYPE llamacpp:predicted_tokens_seconds gauge
llamacpp:predicted_tokens_seconds 42.621031668072597
# HELP llamacpp:requests_processing Number of admitted requests, including cache preparation
# TYPE llamacpp:requests_processing gauge
llamacpp:requests_processing 0
# HELP llamacpp:requests_deferred Number of requests waiting for a session
# TYPE llamacpp:requests_deferred gauge
llamacpp:requests_deferred 0
# HELP llamacpp:kv_cache_usage_ratio In-flight prompt and generated tokens over sessions times context; excludes retained cache
# TYPE llamacpp:kv_cache_usage_ratio gauge
llamacpp:kv_cache_usage_ratio 0
+662
View File
@@ -0,0 +1,662 @@
# HELP python_gc_objects_collected_total Objects collected during gc
# TYPE python_gc_objects_collected_total counter
python_gc_objects_collected_total{generation="0"} 166913.0
python_gc_objects_collected_total{generation="1"} 27377.0
python_gc_objects_collected_total{generation="2"} 1409.0
# HELP python_gc_objects_uncollectable_total Uncollectable objects found during GC
# TYPE python_gc_objects_uncollectable_total counter
python_gc_objects_uncollectable_total{generation="0"} 0.0
python_gc_objects_uncollectable_total{generation="1"} 0.0
python_gc_objects_uncollectable_total{generation="2"} 0.0
# HELP python_gc_collections_total Number of times this generation was collected
# TYPE python_gc_collections_total counter
python_gc_collections_total{generation="0"} 4631.0
python_gc_collections_total{generation="1"} 420.0
python_gc_collections_total{generation="2"} 12.0
# HELP python_info Python platform information
# TYPE python_info gauge
python_info{implementation="CPython",major="3",minor="12",patchlevel="3",version="3.12.3"} 1.0
# HELP process_virtual_memory_bytes Virtual memory size in bytes.
# TYPE process_virtual_memory_bytes gauge
process_virtual_memory_bytes 3.2837738496e+10
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 2.370977792e+09
# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.
# TYPE process_start_time_seconds gauge
process_start_time_seconds 1.79210782535e+09
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 457.6
# HELP process_open_fds Number of open file descriptors.
# TYPE process_open_fds gauge
process_open_fds 43.0
# HELP process_max_fds Maximum number of open file descriptors.
# TYPE process_max_fds gauge
process_max_fds 524288.0
# HELP vllm:estimated_flops_per_gpu_total Estimated number of floating point operations per GPU (for Model Flops Utilization calculations).
# TYPE vllm:estimated_flops_per_gpu_total counter
vllm:estimated_flops_per_gpu_total{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:estimated_flops_per_gpu_created Estimated number of floating point operations per GPU (for Model Flops Utilization calculations).
# TYPE vllm:estimated_flops_per_gpu_created gauge
vllm:estimated_flops_per_gpu_created{engine="0",model_name="juggernaut"} 1.7921081022034154e+09
# HELP vllm:estimated_read_bytes_per_gpu_total Estimated number of bytes read from memory per GPU (for Model Flops Utilization calculations).
# TYPE vllm:estimated_read_bytes_per_gpu_total counter
vllm:estimated_read_bytes_per_gpu_total{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:estimated_read_bytes_per_gpu_created Estimated number of bytes read from memory per GPU (for Model Flops Utilization calculations).
# TYPE vllm:estimated_read_bytes_per_gpu_created gauge
vllm:estimated_read_bytes_per_gpu_created{engine="0",model_name="juggernaut"} 1.792108102203435e+09
# HELP vllm:estimated_write_bytes_per_gpu_total Estimated number of bytes written to memory per GPU (for Model Flops Utilization calculations).
# TYPE vllm:estimated_write_bytes_per_gpu_total counter
vllm:estimated_write_bytes_per_gpu_total{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:estimated_write_bytes_per_gpu_created Estimated number of bytes written to memory per GPU (for Model Flops Utilization calculations).
# TYPE vllm:estimated_write_bytes_per_gpu_created gauge
vllm:estimated_write_bytes_per_gpu_created{engine="0",model_name="juggernaut"} 1.7921081022034492e+09
# HELP vllm:num_requests_running Number of requests in model execution batches.
# TYPE vllm:num_requests_running gauge
vllm:num_requests_running{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:num_requests_waiting Number of requests waiting to be processed.
# TYPE vllm:num_requests_waiting gauge
vllm:num_requests_waiting{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:num_requests_waiting_by_reason Number of waiting requests by reason. Reason labels: 'capacity' = waiting for scheduling capacity; 'deferred' = deferred by transient constraints (LoRA budget, KV transfer, blocked status). Sum of all reasons equals vllm:num_requests_waiting.
# TYPE vllm:num_requests_waiting_by_reason gauge
vllm:num_requests_waiting_by_reason{engine="0",model_name="juggernaut",reason="capacity"} 0.0
vllm:num_requests_waiting_by_reason{engine="0",model_name="juggernaut",reason="deferred"} 0.0
# HELP vllm:engine_sleep_state Engine sleep state; awake = 0 means engine is sleeping; awake = 1 means engine is awake; weights_offloaded = 1 means sleep level 1; discard_all = 1 means sleep level 2.
# TYPE vllm:engine_sleep_state gauge
vllm:engine_sleep_state{engine="0",model_name="juggernaut",sleep_state="awake"} 1.0
vllm:engine_sleep_state{engine="0",model_name="juggernaut",sleep_state="weights_offloaded"} 0.0
vllm:engine_sleep_state{engine="0",model_name="juggernaut",sleep_state="discard_all"} 0.0
# HELP vllm:kv_cache_usage_perc KV-cache usage. 1 means 100 percent usage.
# TYPE vllm:kv_cache_usage_perc gauge
vllm:kv_cache_usage_perc{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:prefix_cache_queries_total Prefix cache queries, in terms of number of queried tokens.
# TYPE vllm:prefix_cache_queries_total counter
vllm:prefix_cache_queries_total{engine="0",model_name="juggernaut"} 1.8389909e+07
# HELP vllm:prefix_cache_queries_created Prefix cache queries, in terms of number of queried tokens.
# TYPE vllm:prefix_cache_queries_created gauge
vllm:prefix_cache_queries_created{engine="0",model_name="juggernaut"} 1.7921081022037191e+09
# HELP vllm:prefix_cache_hits_total Prefix cache hits, in terms of number of cached tokens.
# TYPE vllm:prefix_cache_hits_total counter
vllm:prefix_cache_hits_total{engine="0",model_name="juggernaut"} 1.210496e+07
# HELP vllm:prefix_cache_hits_created Prefix cache hits, in terms of number of cached tokens.
# TYPE vllm:prefix_cache_hits_created gauge
vllm:prefix_cache_hits_created{engine="0",model_name="juggernaut"} 1.792108102203731e+09
# HELP vllm:external_prefix_cache_queries_total External prefix cache queries from KV connector cross-instance cache sharing, in terms of number of queried tokens.
# TYPE vllm:external_prefix_cache_queries_total counter
vllm:external_prefix_cache_queries_total{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:external_prefix_cache_queries_created External prefix cache queries from KV connector cross-instance cache sharing, in terms of number of queried tokens.
# TYPE vllm:external_prefix_cache_queries_created gauge
vllm:external_prefix_cache_queries_created{engine="0",model_name="juggernaut"} 1.7921081022037423e+09
# HELP vllm:external_prefix_cache_hits_total External prefix cache hits from KV connector cross-instance cache sharing, in terms of number of cached tokens.
# TYPE vllm:external_prefix_cache_hits_total counter
vllm:external_prefix_cache_hits_total{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:external_prefix_cache_hits_created External prefix cache hits from KV connector cross-instance cache sharing, in terms of number of cached tokens.
# TYPE vllm:external_prefix_cache_hits_created gauge
vllm:external_prefix_cache_hits_created{engine="0",model_name="juggernaut"} 1.792108102203755e+09
# HELP vllm:mm_cache_queries_total Multi-modal cache queries, in terms of number of queried items.
# TYPE vllm:mm_cache_queries_total counter
vllm:mm_cache_queries_total{engine="0",model_name="juggernaut"} 8.0
# HELP vllm:mm_cache_queries_created Multi-modal cache queries, in terms of number of queried items.
# TYPE vllm:mm_cache_queries_created gauge
vllm:mm_cache_queries_created{engine="0",model_name="juggernaut"} 1.7921081022037659e+09
# HELP vllm:mm_cache_hits_total Multi-modal cache hits, in terms of number of cached items.
# TYPE vllm:mm_cache_hits_total counter
vllm:mm_cache_hits_total{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:mm_cache_hits_created Multi-modal cache hits, in terms of number of cached items.
# TYPE vllm:mm_cache_hits_created gauge
vllm:mm_cache_hits_created{engine="0",model_name="juggernaut"} 1.7921081022037778e+09
# HELP vllm:num_preemptions_total Cumulative number of preemption from the engine.
# TYPE vllm:num_preemptions_total counter
vllm:num_preemptions_total{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:num_preemptions_created Cumulative number of preemption from the engine.
# TYPE vllm:num_preemptions_created gauge
vllm:num_preemptions_created{engine="0",model_name="juggernaut"} 1.7921081022037895e+09
# HELP vllm:prompt_tokens_total Number of prefill tokens processed.
# TYPE vllm:prompt_tokens_total counter
vllm:prompt_tokens_total{engine="0",model_name="juggernaut"} 1.8389909e+07
# HELP vllm:prompt_tokens_created Number of prefill tokens processed.
# TYPE vllm:prompt_tokens_created gauge
vllm:prompt_tokens_created{engine="0",model_name="juggernaut"} 1.7921081022038019e+09
# HELP vllm:prompt_tokens_by_source_total Number of prompt tokens by source.
# TYPE vllm:prompt_tokens_by_source_total counter
vllm:prompt_tokens_by_source_total{engine="0",model_name="juggernaut",source="local_compute"} 6.284949e+06
vllm:prompt_tokens_by_source_total{engine="0",model_name="juggernaut",source="local_cache_hit"} 1.210496e+07
vllm:prompt_tokens_by_source_total{engine="0",model_name="juggernaut",source="external_kv_transfer"} 0.0
# HELP vllm:prompt_tokens_by_source_created Number of prompt tokens by source.
# TYPE vllm:prompt_tokens_by_source_created gauge
vllm:prompt_tokens_by_source_created{engine="0",model_name="juggernaut",source="local_compute"} 1.7921081022038164e+09
vllm:prompt_tokens_by_source_created{engine="0",model_name="juggernaut",source="local_cache_hit"} 1.7921081022038229e+09
vllm:prompt_tokens_by_source_created{engine="0",model_name="juggernaut",source="external_kv_transfer"} 1.7921081022038274e+09
# HELP vllm:prompt_tokens_cached_total Number of cached prompt tokens (local + external).
# TYPE vllm:prompt_tokens_cached_total counter
vllm:prompt_tokens_cached_total{engine="0",model_name="juggernaut"} 1.210496e+07
# HELP vllm:prompt_tokens_cached_created Number of cached prompt tokens (local + external).
# TYPE vllm:prompt_tokens_cached_created gauge
vllm:prompt_tokens_cached_created{engine="0",model_name="juggernaut"} 1.7921081022038393e+09
# HELP vllm:generation_tokens_total Number of generation tokens processed.
# TYPE vllm:generation_tokens_total counter
vllm:generation_tokens_total{engine="0",model_name="juggernaut"} 611163.0
# HELP vllm:generation_tokens_created Number of generation tokens processed.
# TYPE vllm:generation_tokens_created gauge
vllm:generation_tokens_created{engine="0",model_name="juggernaut"} 1.7921081022038517e+09
# HELP vllm:request_success_total Count of successfully processed requests.
# TYPE vllm:request_success_total counter
vllm:request_success_total{engine="0",finished_reason="stop",model_name="juggernaut"} 3488.0
vllm:request_success_total{engine="0",finished_reason="length",model_name="juggernaut"} 0.0
vllm:request_success_total{engine="0",finished_reason="abort",model_name="juggernaut"} 0.0
vllm:request_success_total{engine="0",finished_reason="error",model_name="juggernaut"} 0.0
vllm:request_success_total{engine="0",finished_reason="repetition",model_name="juggernaut"} 0.0
# HELP vllm:request_success_created Count of successfully processed requests.
# TYPE vllm:request_success_created gauge
vllm:request_success_created{engine="0",finished_reason="stop",model_name="juggernaut"} 1.792108102203872e+09
vllm:request_success_created{engine="0",finished_reason="length",model_name="juggernaut"} 1.7921081022038789e+09
vllm:request_success_created{engine="0",finished_reason="abort",model_name="juggernaut"} 1.7921081022038846e+09
vllm:request_success_created{engine="0",finished_reason="error",model_name="juggernaut"} 1.7921081022038896e+09
vllm:request_success_created{engine="0",finished_reason="repetition",model_name="juggernaut"} 1.7921081022038949e+09
# HELP vllm:request_prompt_tokens Number of prefill tokens processed.
# TYPE vllm:request_prompt_tokens histogram
vllm:request_prompt_tokens_bucket{engine="0",le="1.0",model_name="juggernaut"} 0.0
vllm:request_prompt_tokens_bucket{engine="0",le="2.0",model_name="juggernaut"} 0.0
vllm:request_prompt_tokens_bucket{engine="0",le="5.0",model_name="juggernaut"} 0.0
vllm:request_prompt_tokens_bucket{engine="0",le="10.0",model_name="juggernaut"} 0.0
vllm:request_prompt_tokens_bucket{engine="0",le="20.0",model_name="juggernaut"} 5.0
vllm:request_prompt_tokens_bucket{engine="0",le="50.0",model_name="juggernaut"} 5.0
vllm:request_prompt_tokens_bucket{engine="0",le="100.0",model_name="juggernaut"} 30.0
vllm:request_prompt_tokens_bucket{engine="0",le="200.0",model_name="juggernaut"} 133.0
vllm:request_prompt_tokens_bucket{engine="0",le="500.0",model_name="juggernaut"} 612.0
vllm:request_prompt_tokens_bucket{engine="0",le="1000.0",model_name="juggernaut"} 1135.0
vllm:request_prompt_tokens_bucket{engine="0",le="2000.0",model_name="juggernaut"} 2002.0
vllm:request_prompt_tokens_bucket{engine="0",le="5000.0",model_name="juggernaut"} 3126.0
vllm:request_prompt_tokens_bucket{engine="0",le="10000.0",model_name="juggernaut"} 3227.0
vllm:request_prompt_tokens_bucket{engine="0",le="20000.0",model_name="juggernaut"} 3290.0
vllm:request_prompt_tokens_bucket{engine="0",le="50000.0",model_name="juggernaut"} 3383.0
vllm:request_prompt_tokens_bucket{engine="0",le="100000.0",model_name="juggernaut"} 3482.0
vllm:request_prompt_tokens_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_prompt_tokens_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_prompt_tokens_sum{engine="0",model_name="juggernaut"} 1.8352277e+07
# HELP vllm:request_prompt_tokens_created Number of prefill tokens processed.
# TYPE vllm:request_prompt_tokens_created gauge
vllm:request_prompt_tokens_created{engine="0",model_name="juggernaut"} 1.7921081022039344e+09
# HELP vllm:request_generation_tokens Number of generation tokens processed.
# TYPE vllm:request_generation_tokens histogram
vllm:request_generation_tokens_bucket{engine="0",le="1.0",model_name="juggernaut"} 0.0
vllm:request_generation_tokens_bucket{engine="0",le="2.0",model_name="juggernaut"} 0.0
vllm:request_generation_tokens_bucket{engine="0",le="5.0",model_name="juggernaut"} 0.0
vllm:request_generation_tokens_bucket{engine="0",le="10.0",model_name="juggernaut"} 0.0
vllm:request_generation_tokens_bucket{engine="0",le="20.0",model_name="juggernaut"} 6.0
vllm:request_generation_tokens_bucket{engine="0",le="50.0",model_name="juggernaut"} 45.0
vllm:request_generation_tokens_bucket{engine="0",le="100.0",model_name="juggernaut"} 819.0
vllm:request_generation_tokens_bucket{engine="0",le="200.0",model_name="juggernaut"} 3206.0
vllm:request_generation_tokens_bucket{engine="0",le="500.0",model_name="juggernaut"} 3379.0
vllm:request_generation_tokens_bucket{engine="0",le="1000.0",model_name="juggernaut"} 3423.0
vllm:request_generation_tokens_bucket{engine="0",le="2000.0",model_name="juggernaut"} 3464.0
vllm:request_generation_tokens_bucket{engine="0",le="5000.0",model_name="juggernaut"} 3486.0
vllm:request_generation_tokens_bucket{engine="0",le="10000.0",model_name="juggernaut"} 3488.0
vllm:request_generation_tokens_bucket{engine="0",le="20000.0",model_name="juggernaut"} 3488.0
vllm:request_generation_tokens_bucket{engine="0",le="50000.0",model_name="juggernaut"} 3488.0
vllm:request_generation_tokens_bucket{engine="0",le="100000.0",model_name="juggernaut"} 3488.0
vllm:request_generation_tokens_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_generation_tokens_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_generation_tokens_sum{engine="0",model_name="juggernaut"} 610140.0
# HELP vllm:request_generation_tokens_created Number of generation tokens processed.
# TYPE vllm:request_generation_tokens_created gauge
vllm:request_generation_tokens_created{engine="0",model_name="juggernaut"} 1.7921081022039804e+09
# HELP vllm:iteration_tokens_total Histogram of number of tokens per engine_step.
# TYPE vllm:iteration_tokens_total histogram
vllm:iteration_tokens_total_bucket{engine="0",le="1.0",model_name="juggernaut"} 317139.0
vllm:iteration_tokens_total_bucket{engine="0",le="8.0",model_name="juggernaut"} 461626.0
vllm:iteration_tokens_total_bucket{engine="0",le="16.0",model_name="juggernaut"} 461631.0
vllm:iteration_tokens_total_bucket{engine="0",le="32.0",model_name="juggernaut"} 461631.0
vllm:iteration_tokens_total_bucket{engine="0",le="64.0",model_name="juggernaut"} 461632.0
vllm:iteration_tokens_total_bucket{engine="0",le="128.0",model_name="juggernaut"} 461692.0
vllm:iteration_tokens_total_bucket{engine="0",le="256.0",model_name="juggernaut"} 461852.0
vllm:iteration_tokens_total_bucket{engine="0",le="512.0",model_name="juggernaut"} 462266.0
vllm:iteration_tokens_total_bucket{engine="0",le="1024.0",model_name="juggernaut"} 462849.0
vllm:iteration_tokens_total_bucket{engine="0",le="2048.0",model_name="juggernaut"} 463840.0
vllm:iteration_tokens_total_bucket{engine="0",le="4096.0",model_name="juggernaut"} 464991.0
vllm:iteration_tokens_total_bucket{engine="0",le="8192.0",model_name="juggernaut"} 465088.0
vllm:iteration_tokens_total_bucket{engine="0",le="16384.0",model_name="juggernaut"} 465097.0
vllm:iteration_tokens_total_bucket{engine="0",le="+Inf",model_name="juggernaut"} 465099.0
vllm:iteration_tokens_total_count{engine="0",model_name="juggernaut"} 465099.0
vllm:iteration_tokens_total_sum{engine="0",model_name="juggernaut"} 6.896112e+06
# HELP vllm:iteration_tokens_total_created Histogram of number of tokens per engine_step.
# TYPE vllm:iteration_tokens_total_created gauge
vllm:iteration_tokens_total_created{engine="0",model_name="juggernaut"} 1.7921081022040136e+09
# HELP vllm:request_max_num_generation_tokens Histogram of maximum number of requested generation tokens.
# TYPE vllm:request_max_num_generation_tokens histogram
vllm:request_max_num_generation_tokens_bucket{engine="0",le="1.0",model_name="juggernaut"} 0.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="2.0",model_name="juggernaut"} 0.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="5.0",model_name="juggernaut"} 0.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="10.0",model_name="juggernaut"} 0.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="20.0",model_name="juggernaut"} 6.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="50.0",model_name="juggernaut"} 45.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="100.0",model_name="juggernaut"} 819.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="200.0",model_name="juggernaut"} 3206.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="500.0",model_name="juggernaut"} 3379.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="1000.0",model_name="juggernaut"} 3423.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="2000.0",model_name="juggernaut"} 3464.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="5000.0",model_name="juggernaut"} 3486.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="10000.0",model_name="juggernaut"} 3488.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="20000.0",model_name="juggernaut"} 3488.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="50000.0",model_name="juggernaut"} 3488.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="100000.0",model_name="juggernaut"} 3488.0
vllm:request_max_num_generation_tokens_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_max_num_generation_tokens_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_max_num_generation_tokens_sum{engine="0",model_name="juggernaut"} 610140.0
# HELP vllm:request_max_num_generation_tokens_created Histogram of maximum number of requested generation tokens.
# TYPE vllm:request_max_num_generation_tokens_created gauge
vllm:request_max_num_generation_tokens_created{engine="0",model_name="juggernaut"} 1.7921081022040458e+09
# HELP vllm:request_params_n Histogram of the n request parameter.
# TYPE vllm:request_params_n histogram
vllm:request_params_n_bucket{engine="0",le="1.0",model_name="juggernaut"} 3488.0
vllm:request_params_n_bucket{engine="0",le="2.0",model_name="juggernaut"} 3488.0
vllm:request_params_n_bucket{engine="0",le="5.0",model_name="juggernaut"} 3488.0
vllm:request_params_n_bucket{engine="0",le="10.0",model_name="juggernaut"} 3488.0
vllm:request_params_n_bucket{engine="0",le="20.0",model_name="juggernaut"} 3488.0
vllm:request_params_n_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_params_n_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_params_n_sum{engine="0",model_name="juggernaut"} 3488.0
# HELP vllm:request_params_n_created Histogram of the n request parameter.
# TYPE vllm:request_params_n_created gauge
vllm:request_params_n_created{engine="0",model_name="juggernaut"} 1.7921081022040787e+09
# HELP vllm:request_params_max_tokens Histogram of the max_tokens request parameter.
# TYPE vllm:request_params_max_tokens histogram
vllm:request_params_max_tokens_bucket{engine="0",le="1.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="2.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="5.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="10.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="20.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="50.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="100.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="200.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="500.0",model_name="juggernaut"} 0.0
vllm:request_params_max_tokens_bucket{engine="0",le="1000.0",model_name="juggernaut"} 1.0
vllm:request_params_max_tokens_bucket{engine="0",le="2000.0",model_name="juggernaut"} 1.0
vllm:request_params_max_tokens_bucket{engine="0",le="5000.0",model_name="juggernaut"} 3139.0
vllm:request_params_max_tokens_bucket{engine="0",le="10000.0",model_name="juggernaut"} 3140.0
vllm:request_params_max_tokens_bucket{engine="0",le="20000.0",model_name="juggernaut"} 3183.0
vllm:request_params_max_tokens_bucket{engine="0",le="50000.0",model_name="juggernaut"} 3488.0
vllm:request_params_max_tokens_bucket{engine="0",le="100000.0",model_name="juggernaut"} 3488.0
vllm:request_params_max_tokens_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_params_max_tokens_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_params_max_tokens_sum{engine="0",model_name="juggernaut"} 1.7011909e+07
# HELP vllm:request_params_max_tokens_created Histogram of the max_tokens request parameter.
# TYPE vllm:request_params_max_tokens_created gauge
vllm:request_params_max_tokens_created{engine="0",model_name="juggernaut"} 1.7921081022041035e+09
# HELP vllm:time_to_first_token_seconds Histogram of time to first token in seconds.
# TYPE vllm:time_to_first_token_seconds histogram
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.001",model_name="juggernaut"} 0.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.005",model_name="juggernaut"} 0.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.01",model_name="juggernaut"} 0.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.02",model_name="juggernaut"} 0.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.04",model_name="juggernaut"} 0.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.06",model_name="juggernaut"} 0.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.08",model_name="juggernaut"} 4.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.1",model_name="juggernaut"} 4.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.25",model_name="juggernaut"} 592.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 1312.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="0.75",model_name="juggernaut"} 1924.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 2266.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 3433.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 3485.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="7.5",model_name="juggernaut"} 3488.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 3489.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="80.0",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="160.0",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="640.0",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="2560.0",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_count{engine="0",model_name="juggernaut"} 3490.0
vllm:time_to_first_token_seconds_sum{engine="0",model_name="juggernaut"} 2904.5298397541046
# HELP vllm:time_to_first_token_seconds_created Histogram of time to first token in seconds.
# TYPE vllm:time_to_first_token_seconds_created gauge
vllm:time_to_first_token_seconds_created{engine="0",model_name="juggernaut"} 1.7921081022041366e+09
# HELP vllm:inter_token_latency_seconds Histogram of inter-token latency in seconds.
# TYPE vllm:inter_token_latency_seconds histogram
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.01",model_name="juggernaut"} 0.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.025",model_name="juggernaut"} 0.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.05",model_name="juggernaut"} 596060.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.075",model_name="juggernaut"} 605658.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.1",model_name="juggernaut"} 605758.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.15",model_name="juggernaut"} 606021.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.2",model_name="juggernaut"} 606268.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.3",model_name="juggernaut"} 606488.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.4",model_name="juggernaut"} 606669.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 606814.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="0.75",model_name="juggernaut"} 607341.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 607398.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="7.5",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="80.0",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_count{engine="0",model_name="juggernaut"} 607673.0
vllm:inter_token_latency_seconds_sum{engine="0",model_name="juggernaut"} 22579.6616614901
# HELP vllm:inter_token_latency_seconds_created Histogram of inter-token latency in seconds.
# TYPE vllm:inter_token_latency_seconds_created gauge
vllm:inter_token_latency_seconds_created{engine="0",model_name="juggernaut"} 1.7921081022041779e+09
# HELP vllm:request_time_per_output_token_seconds Histogram of time_per_output_token_seconds per request.
# TYPE vllm:request_time_per_output_token_seconds histogram
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.01",model_name="juggernaut"} 0.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.025",model_name="juggernaut"} 0.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.05",model_name="juggernaut"} 3383.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.075",model_name="juggernaut"} 3485.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.1",model_name="juggernaut"} 3487.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.15",model_name="juggernaut"} 3487.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.2",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.3",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.4",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="0.75",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="7.5",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="80.0",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_time_per_output_token_seconds_sum{engine="0",model_name="juggernaut"} 125.03691733873717
# HELP vllm:request_time_per_output_token_seconds_created Histogram of time_per_output_token_seconds per request.
# TYPE vllm:request_time_per_output_token_seconds_created gauge
vllm:request_time_per_output_token_seconds_created{engine="0",model_name="juggernaut"} 1.7921081022042146e+09
# HELP vllm:e2e_request_latency_seconds Histogram of e2e request latency in seconds.
# TYPE vllm:e2e_request_latency_seconds histogram
vllm:e2e_request_latency_seconds_bucket{engine="0",le="0.3",model_name="juggernaut"} 0.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 2.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="0.8",model_name="juggernaut"} 5.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 5.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="1.5",model_name="juggernaut"} 14.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="2.0",model_name="juggernaut"} 57.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 211.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 1389.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 3241.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="15.0",model_name="juggernaut"} 3339.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 3369.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="30.0",model_name="juggernaut"} 3404.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 3418.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="50.0",model_name="juggernaut"} 3436.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="60.0",model_name="juggernaut"} 3449.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="120.0",model_name="juggernaut"} 3480.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="240.0",model_name="juggernaut"} 3486.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="480.0",model_name="juggernaut"} 3488.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="960.0",model_name="juggernaut"} 3488.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="1920.0",model_name="juggernaut"} 3488.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="7680.0",model_name="juggernaut"} 3488.0
vllm:e2e_request_latency_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:e2e_request_latency_seconds_count{engine="0",model_name="juggernaut"} 3488.0
vllm:e2e_request_latency_seconds_sum{engine="0",model_name="juggernaut"} 25442.03079867363
# HELP vllm:e2e_request_latency_seconds_created Histogram of e2e request latency in seconds.
# TYPE vllm:e2e_request_latency_seconds_created gauge
vllm:e2e_request_latency_seconds_created{engine="0",model_name="juggernaut"} 1.7921081022042491e+09
# HELP vllm:request_queue_time_seconds Histogram of time spent in WAITING phase for request.
# TYPE vllm:request_queue_time_seconds histogram
vllm:request_queue_time_seconds_bucket{engine="0",le="0.3",model_name="juggernaut"} 3485.0
vllm:request_queue_time_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 3485.0
vllm:request_queue_time_seconds_bucket{engine="0",le="0.8",model_name="juggernaut"} 3485.0
vllm:request_queue_time_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 3485.0
vllm:request_queue_time_seconds_bucket{engine="0",le="1.5",model_name="juggernaut"} 3487.0
vllm:request_queue_time_seconds_bucket{engine="0",le="2.0",model_name="juggernaut"} 3487.0
vllm:request_queue_time_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 3487.0
vllm:request_queue_time_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="15.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="30.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="50.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="60.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="120.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="240.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="480.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="960.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="1920.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="7680.0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_queue_time_seconds_sum{engine="0",model_name="juggernaut"} 6.650666017842013
# HELP vllm:request_queue_time_seconds_created Histogram of time spent in WAITING phase for request.
# TYPE vllm:request_queue_time_seconds_created gauge
vllm:request_queue_time_seconds_created{engine="0",model_name="juggernaut"} 1.7921081022042854e+09
# HELP vllm:request_inference_time_seconds Histogram of time spent in RUNNING phase for request.
# TYPE vllm:request_inference_time_seconds histogram
vllm:request_inference_time_seconds_bucket{engine="0",le="0.3",model_name="juggernaut"} 0.0
vllm:request_inference_time_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 2.0
vllm:request_inference_time_seconds_bucket{engine="0",le="0.8",model_name="juggernaut"} 6.0
vllm:request_inference_time_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 6.0
vllm:request_inference_time_seconds_bucket{engine="0",le="1.5",model_name="juggernaut"} 14.0
vllm:request_inference_time_seconds_bucket{engine="0",le="2.0",model_name="juggernaut"} 61.0
vllm:request_inference_time_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 214.0
vllm:request_inference_time_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 1394.0
vllm:request_inference_time_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 3248.0
vllm:request_inference_time_seconds_bucket{engine="0",le="15.0",model_name="juggernaut"} 3339.0
vllm:request_inference_time_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 3369.0
vllm:request_inference_time_seconds_bucket{engine="0",le="30.0",model_name="juggernaut"} 3404.0
vllm:request_inference_time_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 3418.0
vllm:request_inference_time_seconds_bucket{engine="0",le="50.0",model_name="juggernaut"} 3436.0
vllm:request_inference_time_seconds_bucket{engine="0",le="60.0",model_name="juggernaut"} 3449.0
vllm:request_inference_time_seconds_bucket{engine="0",le="120.0",model_name="juggernaut"} 3480.0
vllm:request_inference_time_seconds_bucket{engine="0",le="240.0",model_name="juggernaut"} 3486.0
vllm:request_inference_time_seconds_bucket{engine="0",le="480.0",model_name="juggernaut"} 3488.0
vllm:request_inference_time_seconds_bucket{engine="0",le="960.0",model_name="juggernaut"} 3488.0
vllm:request_inference_time_seconds_bucket{engine="0",le="1920.0",model_name="juggernaut"} 3488.0
vllm:request_inference_time_seconds_bucket{engine="0",le="7680.0",model_name="juggernaut"} 3488.0
vllm:request_inference_time_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_inference_time_seconds_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_inference_time_seconds_sum{engine="0",model_name="juggernaut"} 25331.654430066672
# HELP vllm:request_inference_time_seconds_created Histogram of time spent in RUNNING phase for request.
# TYPE vllm:request_inference_time_seconds_created gauge
vllm:request_inference_time_seconds_created{engine="0",model_name="juggernaut"} 1.79210810220433e+09
# HELP vllm:request_prefill_time_seconds Histogram of time spent in PREFILL phase for request.
# TYPE vllm:request_prefill_time_seconds histogram
vllm:request_prefill_time_seconds_bucket{engine="0",le="0.3",model_name="juggernaut"} 844.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 1341.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="0.8",model_name="juggernaut"} 2035.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 2314.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="1.5",model_name="juggernaut"} 3230.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="2.0",model_name="juggernaut"} 3384.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 3444.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 3484.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 3487.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="15.0",model_name="juggernaut"} 3487.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="30.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="50.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="60.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="120.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="240.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="480.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="960.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="1920.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="7680.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_prefill_time_seconds_sum{engine="0",model_name="juggernaut"} 2786.310553313582
# HELP vllm:request_prefill_time_seconds_created Histogram of time spent in PREFILL phase for request.
# TYPE vllm:request_prefill_time_seconds_created gauge
vllm:request_prefill_time_seconds_created{engine="0",model_name="juggernaut"} 1.7921081022043684e+09
# HELP vllm:request_decode_time_seconds Histogram of time spent in DECODE phase for request.
# TYPE vllm:request_decode_time_seconds histogram
vllm:request_decode_time_seconds_bucket{engine="0",le="0.3",model_name="juggernaut"} 0.0
vllm:request_decode_time_seconds_bucket{engine="0",le="0.5",model_name="juggernaut"} 4.0
vllm:request_decode_time_seconds_bucket{engine="0",le="0.8",model_name="juggernaut"} 6.0
vllm:request_decode_time_seconds_bucket{engine="0",le="1.0",model_name="juggernaut"} 7.0
vllm:request_decode_time_seconds_bucket{engine="0",le="1.5",model_name="juggernaut"} 22.0
vllm:request_decode_time_seconds_bucket{engine="0",le="2.0",model_name="juggernaut"} 165.0
vllm:request_decode_time_seconds_bucket{engine="0",le="2.5",model_name="juggernaut"} 452.0
vllm:request_decode_time_seconds_bucket{engine="0",le="5.0",model_name="juggernaut"} 2092.0
vllm:request_decode_time_seconds_bucket{engine="0",le="10.0",model_name="juggernaut"} 3278.0
vllm:request_decode_time_seconds_bucket{engine="0",le="15.0",model_name="juggernaut"} 3354.0
vllm:request_decode_time_seconds_bucket{engine="0",le="20.0",model_name="juggernaut"} 3376.0
vllm:request_decode_time_seconds_bucket{engine="0",le="30.0",model_name="juggernaut"} 3406.0
vllm:request_decode_time_seconds_bucket{engine="0",le="40.0",model_name="juggernaut"} 3422.0
vllm:request_decode_time_seconds_bucket{engine="0",le="50.0",model_name="juggernaut"} 3438.0
vllm:request_decode_time_seconds_bucket{engine="0",le="60.0",model_name="juggernaut"} 3450.0
vllm:request_decode_time_seconds_bucket{engine="0",le="120.0",model_name="juggernaut"} 3481.0
vllm:request_decode_time_seconds_bucket{engine="0",le="240.0",model_name="juggernaut"} 3486.0
vllm:request_decode_time_seconds_bucket{engine="0",le="480.0",model_name="juggernaut"} 3488.0
vllm:request_decode_time_seconds_bucket{engine="0",le="960.0",model_name="juggernaut"} 3488.0
vllm:request_decode_time_seconds_bucket{engine="0",le="1920.0",model_name="juggernaut"} 3488.0
vllm:request_decode_time_seconds_bucket{engine="0",le="7680.0",model_name="juggernaut"} 3488.0
vllm:request_decode_time_seconds_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_decode_time_seconds_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_decode_time_seconds_sum{engine="0",model_name="juggernaut"} 22545.34387675309
# HELP vllm:request_decode_time_seconds_created Histogram of time spent in DECODE phase for request.
# TYPE vllm:request_decode_time_seconds_created gauge
vllm:request_decode_time_seconds_created{engine="0",model_name="juggernaut"} 1.7921081022044446e+09
# HELP vllm:request_num_preemptions Histogram of the number of times a request was preempted.
# TYPE vllm:request_num_preemptions histogram
vllm:request_num_preemptions_bucket{engine="0",le="1.0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_bucket{engine="0",le="2.0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_bucket{engine="0",le="3.0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_bucket{engine="0",le="4.0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_bucket{engine="0",le="5.0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_bucket{engine="0",le="10.0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_bucket{engine="0",le="20.0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_num_preemptions_sum{engine="0",model_name="juggernaut"} 0.0
# HELP vllm:request_num_preemptions_created Histogram of the number of times a request was preempted.
# TYPE vllm:request_num_preemptions_created gauge
vllm:request_num_preemptions_created{engine="0",model_name="juggernaut"} 1.792108102204479e+09
# HELP vllm:request_prefill_kv_computed_tokens Histogram of new KV tokens computed during prefill (excluding cached tokens).
# TYPE vllm:request_prefill_kv_computed_tokens histogram
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="1.0",model_name="juggernaut"} 0.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="2.0",model_name="juggernaut"} 0.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="5.0",model_name="juggernaut"} 0.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="10.0",model_name="juggernaut"} 0.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="20.0",model_name="juggernaut"} 5.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="50.0",model_name="juggernaut"} 6.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="100.0",model_name="juggernaut"} 32.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="200.0",model_name="juggernaut"} 136.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="500.0",model_name="juggernaut"} 628.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="1000.0",model_name="juggernaut"} 1206.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="2000.0",model_name="juggernaut"} 2210.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="5000.0",model_name="juggernaut"} 3423.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="10000.0",model_name="juggernaut"} 3485.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="20000.0",model_name="juggernaut"} 3486.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="50000.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="100000.0",model_name="juggernaut"} 3488.0
vllm:request_prefill_kv_computed_tokens_bucket{engine="0",le="+Inf",model_name="juggernaut"} 3488.0
vllm:request_prefill_kv_computed_tokens_count{engine="0",model_name="juggernaut"} 3488.0
vllm:request_prefill_kv_computed_tokens_sum{engine="0",model_name="juggernaut"} 6.267701e+06
# HELP vllm:request_prefill_kv_computed_tokens_created Histogram of new KV tokens computed during prefill (excluding cached tokens).
# TYPE vllm:request_prefill_kv_computed_tokens_created gauge
vllm:request_prefill_kv_computed_tokens_created{engine="0",model_name="juggernaut"} 1.7921081022045038e+09
# HELP vllm:cache_config_info Information of the LLMEngine CacheConfig
# TYPE vllm:cache_config_info gauge
vllm:cache_config_info{_block_size_resolved="True",block_size="1568",cache_dtype="fp8",enable_mamba_fine_grained_prefix_cache="False",enable_prefix_caching="True",engine="0",gpu_memory_utilization="0.95",is_attention_free="False",kv_cache_dtype_skip_layers="[]",kv_cache_layout="None",kv_cache_max_concurrency="2.1333333333333333",kv_cache_memory_bytes="None",kv_cache_size_tokens="279620",kv_offloading_backend="native",kv_offloading_size="None",kv_sharing_fast_prefill="False",mamba_block_size="1568",mamba_cache_dtype="auto",mamba_cache_mode="align",mamba_page_size_padded="None",mamba_ssm_cache_dtype="float32",num_cpu_blocks="None",num_gpu_blocks="192",num_gpu_blocks_override="None",prefix_cache_retention_interval="0",prefix_caching_hash_algo="sha256",prefix_match_unit="None",replayssm_buffer_len="16",skip_page_size_padded="None",sliding_window="None",use_kda_recoverssm="False",use_replayssm="False",user_specified_block_size="False",user_specified_mamba_block_size="False"} 1.0
# HELP http_requests_total Total number of requests by method, status and handler.
# TYPE http_requests_total counter
http_requests_total{handler="/v1/models",method="GET",status="2xx"} 236.0
http_requests_total{handler="/v1/chat/completions",method="POST",status="2xx"} 3490.0
http_requests_total{handler="/v1/chat/completions",method="POST",status="4xx"} 1.0
# HELP http_requests_created Total number of requests by method, status and handler.
# TYPE http_requests_created gauge
http_requests_created{handler="/v1/models",method="GET",status="2xx"} 1.7921081971217349e+09
http_requests_created{handler="/v1/chat/completions",method="POST",status="2xx"} 1.792108217381334e+09
http_requests_created{handler="/v1/chat/completions",method="POST",status="4xx"} 1.7921641238703468e+09
# HELP http_request_size_bytes Content length of incoming requests by handler. Only value of header is respected. Otherwise ignored. No percentile calculated.
# TYPE http_request_size_bytes summary
http_request_size_bytes_count{handler="/v1/models"} 236.0
http_request_size_bytes_sum{handler="/v1/models"} 0.0
http_request_size_bytes_count{handler="/v1/chat/completions"} 3491.0
http_request_size_bytes_sum{handler="/v1/chat/completions"} 7.4767849e+07
# HELP http_request_size_bytes_created Content length of incoming requests by handler. Only value of header is respected. Otherwise ignored. No percentile calculated.
# TYPE http_request_size_bytes_created gauge
http_request_size_bytes_created{handler="/v1/models"} 1.7921081971218035e+09
http_request_size_bytes_created{handler="/v1/chat/completions"} 1.7921082173813493e+09
# HELP http_response_size_bytes Content length of outgoing responses by handler. Only value of header is respected. Otherwise ignored. No percentile calculated.
# TYPE http_response_size_bytes summary
http_response_size_bytes_count{handler="/v1/models"} 236.0
http_response_size_bytes_sum{handler="/v1/models"} 113516.0
http_response_size_bytes_count{handler="/v1/chat/completions"} 3491.0
http_response_size_bytes_sum{handler="/v1/chat/completions"} 4.443412e+06
# HELP http_response_size_bytes_created Content length of outgoing responses by handler. Only value of header is respected. Otherwise ignored. No percentile calculated.
# TYPE http_response_size_bytes_created gauge
http_response_size_bytes_created{handler="/v1/models"} 1.7921081971218395e+09
http_response_size_bytes_created{handler="/v1/chat/completions"} 1.7921082173813689e+09
# HELP http_request_duration_highr_seconds Latency with many buckets but no API specific labels. Made for more accurate percentile calculations.
# TYPE http_request_duration_highr_seconds histogram
http_request_duration_highr_seconds_bucket{le="0.01"} 236.0
http_request_duration_highr_seconds_bucket{le="0.025"} 236.0
http_request_duration_highr_seconds_bucket{le="0.05"} 236.0
http_request_duration_highr_seconds_bucket{le="0.075"} 236.0
http_request_duration_highr_seconds_bucket{le="0.1"} 236.0
http_request_duration_highr_seconds_bucket{le="0.25"} 237.0
http_request_duration_highr_seconds_bucket{le="0.5"} 239.0
http_request_duration_highr_seconds_bucket{le="0.75"} 241.0
http_request_duration_highr_seconds_bucket{le="1.0"} 242.0
http_request_duration_highr_seconds_bucket{le="1.5"} 251.0
http_request_duration_highr_seconds_bucket{le="2.0"} 294.0
http_request_duration_highr_seconds_bucket{le="2.5"} 446.0
http_request_duration_highr_seconds_bucket{le="3.0"} 678.0
http_request_duration_highr_seconds_bucket{le="3.5"} 917.0
http_request_duration_highr_seconds_bucket{le="4.0"} 1126.0
http_request_duration_highr_seconds_bucket{le="4.5"} 1351.0
http_request_duration_highr_seconds_bucket{le="5.0"} 1625.0
http_request_duration_highr_seconds_bucket{le="7.5"} 3191.0
http_request_duration_highr_seconds_bucket{le="10.0"} 3478.0
http_request_duration_highr_seconds_bucket{le="30.0"} 3643.0
http_request_duration_highr_seconds_bucket{le="60.0"} 3688.0
http_request_duration_highr_seconds_bucket{le="+Inf"} 3727.0
http_request_duration_highr_seconds_count 3727.0
http_request_duration_highr_seconds_sum 25494.72712990234
# HELP http_request_duration_highr_seconds_created Latency with many buckets but no API specific labels. Made for more accurate percentile calculations.
# TYPE http_request_duration_highr_seconds_created gauge
http_request_duration_highr_seconds_created 1.7921081024104605e+09
# HELP http_request_duration_seconds Latency with only few buckets by handler. Made to be only used if aggregation by handler is important.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{handler="/v1/models",le="0.1",method="GET"} 236.0
http_request_duration_seconds_bucket{handler="/v1/models",le="0.5",method="GET"} 236.0
http_request_duration_seconds_bucket{handler="/v1/models",le="1.0",method="GET"} 236.0
http_request_duration_seconds_bucket{handler="/v1/models",le="+Inf",method="GET"} 236.0
http_request_duration_seconds_count{handler="/v1/models",method="GET"} 236.0
http_request_duration_seconds_sum{handler="/v1/models",method="GET"} 0.09283922897884622
http_request_duration_seconds_bucket{handler="/v1/chat/completions",le="0.1",method="POST"} 0.0
http_request_duration_seconds_bucket{handler="/v1/chat/completions",le="0.5",method="POST"} 3.0
http_request_duration_seconds_bucket{handler="/v1/chat/completions",le="1.0",method="POST"} 6.0
http_request_duration_seconds_bucket{handler="/v1/chat/completions",le="+Inf",method="POST"} 3491.0
http_request_duration_seconds_count{handler="/v1/chat/completions",method="POST"} 3491.0
http_request_duration_seconds_sum{handler="/v1/chat/completions",method="POST"} 25494.634290673363
# HELP http_request_duration_seconds_created Latency with only few buckets by handler. Made to be only used if aggregation by handler is important.
# TYPE http_request_duration_seconds_created gauge
http_request_duration_seconds_created{handler="/v1/models",method="GET"} 1.7921081971218753e+09
http_request_duration_seconds_created{handler="/v1/chat/completions",method="POST"} 1.7921082173813894e+09
# HELP vllm:tool_call_parser_invocations_total Total number of ToolParser invocations. Non-streaming increments once per choice; streaming increments once per delta.
# TYPE vllm:tool_call_parser_invocations_total counter
vllm:tool_call_parser_invocations_total{mode="streaming",model_name="juggernaut",outcome="tool_call",request_type="chat_completions"} 31000.0
vllm:tool_call_parser_invocations_total{mode="streaming",model_name="juggernaut",outcome="tool_call",request_type="responses"} 0.0
vllm:tool_call_parser_invocations_total{mode="streaming",model_name="juggernaut",outcome="tool_call",request_type="other"} 0.0
vllm:tool_call_parser_invocations_total{mode="streaming",model_name="juggernaut",outcome="no_tool_call",request_type="chat_completions"} 42734.0
vllm:tool_call_parser_invocations_total{mode="streaming",model_name="juggernaut",outcome="no_tool_call",request_type="responses"} 0.0
vllm:tool_call_parser_invocations_total{mode="streaming",model_name="juggernaut",outcome="no_tool_call",request_type="other"} 0.0
vllm:tool_call_parser_invocations_total{mode="non_streaming",model_name="juggernaut",outcome="tool_call",request_type="chat_completions"} 0.0
vllm:tool_call_parser_invocations_total{mode="non_streaming",model_name="juggernaut",outcome="tool_call",request_type="responses"} 0.0
vllm:tool_call_parser_invocations_total{mode="non_streaming",model_name="juggernaut",outcome="tool_call",request_type="other"} 0.0
vllm:tool_call_parser_invocations_total{mode="non_streaming",model_name="juggernaut",outcome="no_tool_call",request_type="chat_completions"} 3139.0
vllm:tool_call_parser_invocations_total{mode="non_streaming",model_name="juggernaut",outcome="no_tool_call",request_type="responses"} 0.0
vllm:tool_call_parser_invocations_total{mode="non_streaming",model_name="juggernaut",outcome="no_tool_call",request_type="other"} 0.0
# HELP vllm:tool_call_parser_invocations_created Total number of ToolParser invocations. Non-streaming increments once per choice; streaming increments once per delta.
# TYPE vllm:tool_call_parser_invocations_created gauge
vllm:tool_call_parser_invocations_created{mode="streaming",model_name="juggernaut",outcome="tool_call",request_type="chat_completions"} 1.7921081024108987e+09
vllm:tool_call_parser_invocations_created{mode="streaming",model_name="juggernaut",outcome="tool_call",request_type="responses"} 1.792108102410908e+09
vllm:tool_call_parser_invocations_created{mode="streaming",model_name="juggernaut",outcome="tool_call",request_type="other"} 1.7921081024109144e+09
vllm:tool_call_parser_invocations_created{mode="streaming",model_name="juggernaut",outcome="no_tool_call",request_type="chat_completions"} 1.7921081024109201e+09
vllm:tool_call_parser_invocations_created{mode="streaming",model_name="juggernaut",outcome="no_tool_call",request_type="responses"} 1.7921081024109259e+09
vllm:tool_call_parser_invocations_created{mode="streaming",model_name="juggernaut",outcome="no_tool_call",request_type="other"} 1.792108102410931e+09
vllm:tool_call_parser_invocations_created{mode="non_streaming",model_name="juggernaut",outcome="tool_call",request_type="chat_completions"} 1.7921081024109368e+09
vllm:tool_call_parser_invocations_created{mode="non_streaming",model_name="juggernaut",outcome="tool_call",request_type="responses"} 1.7921081024109426e+09
vllm:tool_call_parser_invocations_created{mode="non_streaming",model_name="juggernaut",outcome="tool_call",request_type="other"} 1.7921081024109476e+09
vllm:tool_call_parser_invocations_created{mode="non_streaming",model_name="juggernaut",outcome="no_tool_call",request_type="chat_completions"} 1.792108102410953e+09
vllm:tool_call_parser_invocations_created{mode="non_streaming",model_name="juggernaut",outcome="no_tool_call",request_type="responses"} 1.7921081024109583e+09
vllm:tool_call_parser_invocations_created{mode="non_streaming",model_name="juggernaut",outcome="no_tool_call",request_type="other"} 1.7921081024109638e+09
+284
View File
@@ -0,0 +1,284 @@
"""The host agent: stdlib-only, read-only by construction, and wrong on no box.
Three things are asserted here:
* parsing of /proc and /sys files exactly as Linux writes them (fake roots, so the
weird cases are testable on any machine);
* the collector produces a complete, non-crashing snapshot against the *real* /proc of
whatever runs the tests, because that is the code that ships;
* the HTTP surface answers GETs, needs a token when it is exposed, and has no write path.
"""
from __future__ import annotations
import importlib.util
import json
import re
import sys
import threading
import time
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
AGENT_PATH = ROOT / "agent" / "host_agent.py"
spec = importlib.util.spec_from_file_location("host_agent", AGENT_PATH)
assert spec and spec.loader
host_agent = importlib.util.module_from_spec(spec)
sys.modules["host_agent"] = host_agent
spec.loader.exec_module(host_agent)
linux_only = pytest.mark.skipif(not sys.platform.startswith("linux"),
reason="reads /proc and /sys")
# ---- source-level helpers ------------------------------------------------- #
@pytest.mark.parametrize("lspci,expected", [
("Advanced Micro Devices, Inc. [AMD/ATI] Navi 48 [Radeon AI PRO R9700]",
"Radeon AI PRO R9700"),
("Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S "
"Graphics / Radeon 8060S Graphics]", "Radeon 8060S Graphics"),
("Advanced Micro Devices, Inc. [AMD/ATI] Phoenix1", "Phoenix1"),
("00:02.0 VGA compatible controller: Intel Corporation UHD Graphics 620",
"UHD Graphics 620"),
("NVIDIA GB200", "NVIDIA GB200"),
("Vendor 1af1 Device 0001", "Vendor 1af1 Device 0001"),
(None, None),
("", None),
])
def test_gpu_model_is_shortened_for_legends_without_losing_identity(lspci, expected) -> None:
"""`ATI` on a legend is worse than no name: it is true of every AMD GPU on the box."""
assert host_agent.short_gpu_model(lspci) == expected
def test_gpu_model_is_capped_so_a_legend_cannot_blow_up() -> None:
long_name = "X" * 200
assert len(host_agent.short_gpu_model(long_name)) <= 41 # 40 + ellipsis
# ---- /proc and /sys parsing (fake roots) ---------------------------------- #
def write_file(root: Path, relative: str, text: str) -> None:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
def test_read_number_takes_the_first_field(tmp_path: Path) -> None:
"""/proc/uptime is `210036.18 9613750.86`; float() of the whole line is None."""
write_file(tmp_path, "proc/uptime", "210036.18 9613750.86\n")
collector = host_agent.Collector(str(tmp_path))
assert collector.read_number("proc/uptime") == 210036.18
def test_read_number_handles_missing_and_junk_files(tmp_path: Path) -> None:
collector = host_agent.Collector(str(tmp_path))
assert collector.read_number("proc/nope") is None
write_file(tmp_path, "proc/junk", "\n")
assert collector.read_number("proc/junk") is None
def test_core_counts_dedupe_socket_and_core(tmp_path: Path) -> None:
"""One /proc/cpuinfo block per *thread*: counting 'core id' lines said '1 core'."""
blocks = []
for thread in range(8): # 2 sockets x 2 cores x 2 threads
socket, core = thread % 2, (thread // 2) % 2
blocks.append(f"processor\t: {thread}\nphysical id\t: {socket}\ncore id\t\t: {core}\n")
write_file(tmp_path, "proc/cpuinfo", "\n".join(blocks))
collector = host_agent.Collector(str(tmp_path))
assert collector.core_counts() == (8, 4)
def test_core_counts_falls_back_to_smt_width_on_sysfs(tmp_path: Path) -> None:
"""Some kernels/vms give no physical id/core id; cpu0's sibling list still says SMT=2."""
write_file(tmp_path, "proc/cpuinfo", "processor\t: 0\nprocessor\t: 1\n")
write_file(tmp_path, "sys/devices/system/cpu/cpu0/topology/thread_siblings_list", "0,1\n")
collector = host_agent.Collector(str(tmp_path))
logical, physical = collector.core_counts()
assert (logical, physical) == (2, 1)
def test_core_counts_survive_a_kernel_with_no_hints(tmp_path: Path) -> None:
collector = host_agent.Collector(str(tmp_path))
assert collector.core_counts()[1] is None, "unknown must be None, never 1"
def test_cpu_modes_total_excludes_guest_ticks(tmp_path: Path) -> None:
"""/proc/stat's idle/iowait are already inside 'total'; guest is inside user on Linux."""
write_file(tmp_path, "proc/stat",
"cpu 100 10 20 500 5 3 2 1 0 0\nintr 1\nctxt 2\nprocesses 3\n")
collector = host_agent.Collector(str(tmp_path))
sample = collector.cpu()["modes"]
assert sample["user"] == 100 and sample["idle"] == 500
assert sample["total"] == pytest.approx(100 + 10 + 20 + 500 + 5 + 3 + 2 + 1)
# ---- the real collector --------------------------------------------------- #
@linux_only
def test_snapshot_against_the_live_system_is_complete() -> None:
collector = host_agent.Collector("/")
snap = collector.snapshot()
for key in ("schema", "ts", "host", "cpu", "memory", "filesystems", "disk_io",
"network", "gpus", "temperatures", "processes", "errors"):
assert key in snap, f"{key} missing from a real snapshot"
assert snap["host"]["hostname"]
assert snap["cpu"]["modes"]["total"] > 0
assert len(snap["cpu"]["cores"]) >= 1
assert snap["memory"]["total_bytes"] > 0
assert snap["filesystems"], "the box it runs on has at least one mounted filesystem"
assert snap["errors"] == [], f"degraded sections must be empty on a healthy box: {snap['errors']}"
def test_captured_fixture_has_the_shape_the_dashboard_depends_on(agent_snapshot) -> None:
"""Ground truth from driveripper: if the agent's schema drifts, this fails first."""
assert agent_snapshot["schema"] == host_agent.SCHEMA_VERSION
host = agent_snapshot["host"]
assert host["hostname"] == "driveripper"
assert (host["cpu_cores_physical"], host["cpu_cores_logical"]) == (24, 48)
assert host["uptime_seconds"] > 0
gpu = agent_snapshot["gpus"][0]
assert gpu["model"] == "Radeon AI PRO R9700"
assert "model_full" in gpu and "Navi 48" in gpu["model_full"]
assert gpu["vram_total_bytes"] > 0
# the dashboard renders n/a for anything a driver omits — it must never invent a 0
assert gpu["busy_pct"] is None or 0.0 <= gpu["busy_pct"] <= 100.0
@linux_only
def test_uptime_and_boot_ts_are_reported() -> None:
"""Both are needed: uptime for the table, boot_ts to detect a counter-reset reboot."""
info = host_agent.Collector("/").host_info()
assert info["uptime_seconds"] and info["uptime_seconds"] > 0
assert info["boot_ts"] and info["boot_ts"] <= time.time()
assert abs((time.time() - info["boot_ts"]) - info["uptime_seconds"]) < 60
assert info["cpu_cores_logical"] and info["cpu_cores_physical"]
@linux_only
def test_snapshot_is_repeatable_and_does_not_deadlock() -> None:
"""snapshot() reads processes() internally; a non-reentrant lock used to hang it."""
collector = host_agent.Collector("/")
first = collector.snapshot()
second = collector.snapshot(top=0)
assert first["processes"]["total"] > 0
assert second["processes"]["top_cpu"] == [], "top=0 must skip the expensive scan"
@linux_only
def test_disk_io_comes_from_sysfs_not_proc_diskstats() -> None:
"""/proc/diskstats field order has moved between kernels; /sys/block/<dev>/stat has not."""
devices = host_agent.Collector("/").disk_io()["devices"]
assert devices
for device in devices:
assert device["reads_total"] is None or device["reads_total"] >= 0
assert device["size_bytes"] is None or device["size_bytes"] > 0
# ---- HTTP surface --------------------------------------------------------- #
@pytest.fixture
def agent_server():
"""Run the real handler on an ephemeral loopback port; yield (get, port)."""
servers: list[ThreadingHTTPServer] = []
def start(token: str | None = None) -> tuple[str, int]:
host_agent.Handler.collector = host_agent.Collector("/")
host_agent.Handler.token = token
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
servers.append(httpd)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return f"http://127.0.0.1:{httpd.server_address[1]}", httpd.server_address[1]
yield start
for httpd in servers:
httpd.shutdown()
httpd.server_close()
def _get(url: str, headers: dict[str, str] | None = None) -> tuple[int, dict]:
request = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
body = error.read()
try:
return error.code, json.loads(body)
except json.JSONDecodeError: # pragma: no cover - handler always sends JSON
return error.code, {"raw": body.decode(errors="replace")}
def test_health_info_schema_snapshot_all_answer(agent_server) -> None:
base, _port = agent_server()
for path in ("/health", "/info", "/schema", "/snapshot"):
status, body = _get(base + path)
assert status == 200, path
assert body["ok"] is True, path
def test_snapshot_query_param_controls_the_process_scan(agent_server) -> None:
base, _port = agent_server()
_status, body = _get(base + "/snapshot?top=0")
assert body["processes"]["top_cpu"] == []
_status, full = _get(base + "/snapshot?top=3")
assert len(full["processes"]["top_cpu"]) <= 3
def test_unknown_path_is_404_not_a_snapshot(agent_server) -> None:
base, _port = agent_server()
status, body = _get(base + "/etc/shadow")
assert status == 404
assert body["ok"] is False
def test_token_is_required_when_configured_but_health_stays_open(agent_server) -> None:
base, _port = agent_server(token="s3cret")
assert _get(base + "/snapshot")[0] == 401
assert _get(base + "/snapshot", {"Authorization": "Bearer wrong"})[0] == 401
assert _get(base + "/snapshot", {"Authorization": "Bearer s3cret"})[0] == 200
# /health stays open so a monitoring probe needs no secret
assert _get(base + "/health")[0] == 200
def test_agent_has_no_write_path(agent_server) -> None:
"""No do_POST/do_PUT exists: read-only is structural, not a policy toggle."""
base, _port = agent_server()
request = urllib.request.Request(base + "/snapshot", data=b"{}", method="POST")
with pytest.raises(urllib.error.HTTPError) as excinfo:
urllib.request.urlopen(request, timeout=5)
assert excinfo.value.code in (405, 501)
def test_refuses_to_bind_off_loopback_without_a_token() -> None:
"""0.0.0.0 with no token would hand host inventory to the whole LAN."""
assert host_agent.main(["--host", "0.0.0.0", "--port", "0"]) == 2
def test_once_mode_prints_json_and_exits(capsys) -> None:
assert host_agent.main(["--once"]) == 0
printed = capsys.readouterr().out
assert json.loads(printed)["ok"] is True
def test_agent_is_dependency_free() -> None:
"""The agent must run on a stock distro python: stdlib imports, and few of them.
Two checks, because they fail for different reasons: anything outside the stdlib means a
`pip install` on every monitored host (deployment pain), while a growing footprint means
the agent can break on an older interpreter than the dashboard's own Python.
"""
text = AGENT_PATH.read_text()
imports = re.findall(r"^(?:import|from)\s+([a-zA-Z0-9_.]+)", text, re.M)
# indented (function-local) imports count too — subprocess is loaded lazily on purpose
imports += re.findall(r"^\s+(?:import|from)\s+([a-zA-Z0-9_.]+)", text, re.M)
top = {name.split(".")[0] for name in imports}
third_party = top - set(sys.stdlib_module_names) - {"host_agent"}
assert not third_party, f"agent grew a non-stdlib import: {third_party}"
assert top <= {"__future__", "argparse", "collections", "dataclasses", "http", "json",
"os", "pathlib", "re", "socket", "subprocess", "threading", "time",
"typing"}, f"agent import footprint grew: {sorted(top)}"
+346
View File
@@ -0,0 +1,346 @@
"""HTTP contract: what the pages may assume, and what must never leak.
The behaviours here are the ones a screenshot cannot show: an unconfigured source answering
404 `configured: false` instead of dialling a hostname that does not exist; a bearer token
that stops at the backend; the single write endpoint refusing on every gate; no external
asset the network can fail to deliver.
"""
from __future__ import annotations
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from dashboards.app import create_app
from dashboards.config import Settings, Target
from dashboards.store import Event, Sample, SampleStore
# refused immediately, and never dials out of the test environment
DEAD_URL = "http://127.0.0.1:1"
def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False,
**overrides: Any) -> TestClient:
"""A TestClient whose lifespan runs, against a private DB and no real network."""
if simulate:
monkeypatch.setenv("NUT_SIMULATE", "1")
else:
monkeypatch.delenv("NUT_SIMULATE", raising=False)
db = str(tmp_path / "api.sqlite3")
settings = Settings(db_path=db, **overrides)
store = SampleStore(db)
app = create_app(settings, store)
client = TestClient(app)
client.__enter__()
return client
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
with build_client(tmp_path, monkeypatch) as test_client:
yield test_client
@pytest.fixture
def ups_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
"""NUT on the labelled simulator, actions allowed — the dev configuration."""
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
nut_allow_actions=True) as c:
yield c
def wait_for_payload(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]:
deadline = time.time() + timeout
while time.time() < deadline:
response = client.get(path)
if response.status_code == 200:
return response.json()
time.sleep(0.1)
pytest.fail(f"{path} never became available")
# ---- unconfigured: say so, and do not dial -------------------------------- #
def test_config_reports_every_source_as_off(client) -> None:
body = client.get("/api/config").json()
assert body["system"]["enabled"] is False
assert body["system"]["targets"] == []
assert body["llm"]["gufo"]["enabled"] is False
assert body["llm"]["vllm"]["enabled"] is False
assert body["ups"]["enabled"] is False
assert body["ups"]["actions_enabled"] is False
assert body["ranges"], "the page builds its window buttons from this list"
def test_health_with_nothing_configured_is_not_a_failure(client) -> None:
"""Nothing configured is 'empty', not 'broken' — and the page must not go red."""
body = client.get("/api/health").json()
assert body["status"] in ("ok", "empty")
assert body["ok"] is True
assert body["sources"] == []
@pytest.mark.parametrize("path", ["/api/system/snapshot", "/api/llm/snapshot",
"/api/ups/snapshot", "/api/ups/commands"])
def test_unconfigured_source_is_404_configured_false(client, path) -> None:
response = client.get(path)
assert response.status_code == 404
assert response.json()["configured"] is False
assert "detail" in response.json()
def test_summary_still_answers_with_no_sources(client) -> None:
body = client.get("/api/summary").json()
assert body["system"]["enabled"] is False
assert body["system"]["fleet"] is None
assert body["llm"]["enabled"] is False
assert body["ups"]["enabled"] is False
def test_unknown_route_is_json_not_html(client) -> None:
response = client.get("/api/nope")
assert response.status_code == 404
assert response.headers["content-type"].startswith("application/json")
assert "no such route" in response.json()["detail"]
# ---- tokens --------------------------------------------------------------- #
def test_agent_tokens_never_reach_the_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NUT_SIMULATE", "")
targets = [Target(name="driveripper", url="http://driveripper:9120",
headers={"Authorization": "Bearer SEKRIT-TOKEN"})]
db = str(tmp_path / "tokens.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, host_targets=tuple(targets)), store)
with TestClient(app) as client:
body = client.get("/api/config")
assert "SEKRIT-TOKEN" not in body.text
target = body.json()["system"]["targets"][0]
assert target["name"] == "driveripper"
assert "SEKRIT" not in target["url"] and "@" not in target["url"]
# and a host that never answered stays in the list, marked, with the fleet card intact
snapshot = wait_for_state(client, "/api/system/snapshot")
assert snapshot["hosts"][0]["name"] == "driveripper"
assert snapshot["hosts"][0].get("unreachable") is True
assert snapshot["fleet"]["hosts_total"] == 1
assert snapshot["fleet"]["hosts_live"] == 0
assert snapshot["fleet"]["memory_total_bytes"] is None
store.close()
def wait_for_state(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]:
deadline = time.time() + timeout
while time.time() < deadline:
response = client.get(path)
if response.status_code == 200:
return response.json()
time.sleep(0.1)
pytest.fail(f"{path} never answered")
def test_target_problems_are_surfaced_not_swallowed(client) -> None:
body = client.get("/api/config").json()
assert body["system"]["target_problems"] == []
# ---- pages ---------------------------------------------------------------- #
@pytest.mark.parametrize("path,title", [
("/", "overview"), ("/system", "system"), ("/llm", "llm"), ("/ups", "ups"),
])
def test_pages_serve_and_declare_themselves(client, path, title) -> None:
response = client.get(path)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert title in response.text.lower()
def test_no_page_loads_anything_from_the_internet(client) -> None:
"""No CDN, no webfonts: this dashboard has to work with no route to the internet."""
for path in ("/", "/system", "/llm", "/ups"):
html = client.get(path).text
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
assert fragment not in html, f"{path} references {fragment}"
def test_static_assets_are_served(client) -> None:
for asset in ("/static/dash.css", "/static/app.js", "/static/charts.js",
"/static/page-system.js", "/static/page-llm.js", "/static/page-ups.js",
"/static/page-index.js"):
assert client.get(asset).status_code == 200, asset
def test_api_responses_are_not_cacheable(client) -> None:
for path in ("/api/config", "/api/health", "/api/summary"):
assert "no-store" in client.get(path).headers.get("cache-control", "")
# ---- history -------------------------------------------------------------- #
def test_history_returns_seeded_samples(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
db = str(tmp_path / "hist.sqlite3")
store = SampleStore(db)
now = time.time()
for i in range(30):
store.add("host", "driveripper", "cpu.usage_pct", 10.0 + i, ts=now - 300 + i)
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)), store)
with TestClient(app) as client:
body = client.get("/api/history", params={"source": "host", "host": "driveripper",
"metric": "cpu.usage_pct", "range": "15m"})
assert response_ok(body)
points = body.json()["points"]
assert len(points) >= 25
assert points[-1]["value"] == pytest.approx(39.0)
store.close()
def test_history_rejects_an_unknown_range(client) -> None:
response = client.get("/api/history", params={"source": "host", "host": "h",
"metric": "m", "range": "banana"})
assert response.status_code == 400
def test_history_requires_the_series_coordinates(client) -> None:
assert client.get("/api/history").status_code == 422
def test_metrics_endpoint_lists_what_actually_has_history(client) -> None:
body = client.get("/api/metrics").json()
assert body["metrics"] == []
# ---- events --------------------------------------------------------------- #
def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
db = str(tmp_path / "events.sqlite3")
store = SampleStore(db)
store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down",
severity="error", message="deskwork is not answering"))
app = create_app(Settings(db_path=db), store)
with TestClient(app) as client:
events = client.get("/api/events").json()["events"]
assert events[0]["host"] == "deskwork"
assert events[0]["severity"] == "error"
store.close()
# ---- the one write endpoint ------------------------------------------------ #
def test_command_endpoint_is_post_only(ups_client) -> None:
assert ups_client.post("/api/ups/command", json={}).status_code in (400, 403, 404)
assert ups_client.put("/api/ups/command", json={}).status_code == 405
assert ups_client.delete("/api/ups/command").status_code == 405
def test_commands_are_offered_when_actions_are_enabled(ups_client) -> None:
body = wait_for_payload(ups_client, "/api/ups/snapshot")
assert body["simulated"] is True, "the simulator must label itself"
assert body["actions_enabled"] is True
ups = body["ups"][0]
enabled = [item for item in ups["commands"] if item["enabled"]]
assert enabled, "with NUT_ALLOW_ACTIONS=1 and a simulator, safe commands are sendable"
assert all(item["risk"] in ("safe", "caution", "dangerous") for item in ups["commands"])
def test_a_safe_command_actually_reaches_the_driver(ups_client) -> None:
body = wait_for_payload(ups_client, "/api/ups/snapshot")
name = body["ups"][0]["name"]
response = ups_client.post("/api/ups/command",
json={"ups": name, "command": "test.panel.start"})
assert response.status_code == 200, response.text
payload = response.json()
assert payload["ok"] is True and payload["simulated"] is True
events = ups_client.get("/api/events").json()["events"]
assert any("test.panel.start" in event["message"] or "Panel test" in event["message"]
for event in events), "every sent command must leave an audit trail"
def test_a_dangerous_command_needs_the_confirmation_word(ups_client) -> None:
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
deep = "test.battery.start.deep"
refusal = ups_client.post("/api/ups/command", json={"ups": name, "command": deep})
assert refusal.status_code == 422
assert "DANGER" in refusal.json()["detail"]
assert ups_client.post("/api/ups/command",
json={"ups": name, "command": deep, "confirm": "DANGER"}).status_code == 200
def test_unknown_commands_are_refused_even_with_actions_on(ups_client) -> None:
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
for command in ("load.format", "rm -rf /", "INSTCMD x y"):
response = ups_client.post("/api/ups/command", json={"ups": name, "command": command})
assert response.status_code == 400, command
def test_commands_for_an_unknown_ups_are_404(ups_client) -> None:
wait_for_payload(ups_client, "/api/ups/snapshot")
response = ups_client.post("/api/ups/command",
json={"ups": "not-real", "command": "beeper.mute"})
assert response.status_code == 404
def test_missing_fields_are_a_400(ups_client) -> None:
assert ups_client.post("/api/ups/command", json={"ups": ""}).status_code == 400
def test_actions_flag_off_blocks_every_command(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1") as client:
body = wait_for_payload(client, "/api/ups/snapshot")
name = body["ups"][0]["name"]
assert body["actions_enabled"] is False
response = client.post("/api/ups/command",
json={"ups": name, "command": "beeper.mute"})
assert response.status_code == 403
assert "NUT_ALLOW_ACTIONS" in response.json()["detail"]
def test_read_only_beats_the_actions_flag(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
nut_allow_actions=True, read_only=True) as client:
body = wait_for_payload(client, "/api/ups/snapshot")
name = body["ups"][0]["name"]
response = client.post("/api/ups/command",
json={"ups": name, "command": "beeper.mute"})
assert response.status_code == 403
commands = wait_for_payload(client, "/api/ups/commands")
assert commands["read_only"] is True
assert all(item["enabled"] is False for ups in commands["ups"]
for item in ups["commands"])
def test_ups_history_answers_for_simulated_ups(ups_client) -> None:
wait_for_payload(ups_client, "/api/ups/snapshot")
time.sleep(1.2)
body = ups_client.get("/api/ups/history", params={"metrics": "ups.charge_pct",
"range": "1h"}).json()
assert body["ups"], "the UPS page needs a name to chart before any history exists"
def response_ok(response) -> bool:
assert response.status_code == 200, response.text
return True
def test_samples_written_by_the_poller_are_queryable(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
"""The write path (poller -> store) and the read path (/api/history) must agree on names."""
db = str(tmp_path / "roundtrip.sqlite3")
store = SampleStore(db)
store.add_many([Sample("llm", "gufo", "llm.decode_tokens_per_second", 40.0, time.time() - 60),
Sample("llm", "gufo", "llm.decode_tokens_per_second", 45.0, time.time() - 30)])
app = create_app(Settings(db_path=db), store)
with TestClient(app) as client:
metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"]
assert "llm.decode_tokens_per_second" in metrics
points = client.get("/api/history", params={"source": "llm", "host": "gufo",
"metric": "llm.decode_tokens_per_second",
"range": "15m"}).json()["points"]
assert [p["value"] for p in points] == [40.0, 45.0]
store.close()
+443
View File
@@ -0,0 +1,443 @@
"""Collectors: the fetch half of every poll, exercised with a fake transport.
`httpx.MockTransport` means these run at the speed of the parser and still cover what the
live hosts do badly: a 401 from a rotated agent token, a half-degraded agent snapshot, a
gufo server that only answers `/v1/slots`, an engine that counts nothing yet.
NUT is covered here for the *monitor* bookkeeping (payload shape, command gating, status
transitions); the socket protocol itself is in test_nut.py.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, ClassVar
import httpx
import pytest
from dashboards.collectors import (
NutMonitor,
UpstreamError,
fetch_engine,
fetch_host_snapshot,
)
from dashboards.config import Target
from dashboards.rates import RateEstimator
from dashboards.sources import nut
from dashboards.sources.host import HostCollector
FIXTURES = Path(__file__).parent / "fixtures"
def client_for(handler: Any) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler),
base_url="http://agent.test")
# --------------------------------------------------------------------------- #
# host agent
# --------------------------------------------------------------------------- #
def agent_body() -> dict[str, Any]:
return json.loads((FIXTURES / "agent_driveripper.json").read_text())
def advance_cpu(body: dict[str, Any], *, total: float, idle: float) -> None:
"""Move the jiffy counters the way a real /proc/stat does between polls."""
modes = body["cpu"]["modes"]
for mode, value in modes.items():
modes[mode] = value + (total if mode == "total" else idle if mode == "idle" else 0)
async def test_a_real_agent_snapshot_parses() -> None:
body = agent_body()
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/snapshot"
calls.append(request.url.path)
return httpx.Response(200, json=body)
target = Target("driveripper", "http://agent.test")
collector = HostCollector(RateEstimator())
async with client_for(handler) as client:
first = (await fetch_host_snapshot(client, target, collector)).payload
# CPU usage is Δjiffies/Δt: one scrape has no window, so it is n/a and not 0 %
assert first["cpu"]["usage_pct"] is None
# the counters have to actually move — total 0 delta means "cannot divide", not idle
same = (await fetch_host_snapshot(client, target, collector)).payload
assert same["cpu"]["usage_pct"] is None, "no counter movement must read as n/a"
advance_cpu(body, total=1000, idle=700)
result = await fetch_host_snapshot(client, target, collector)
payload = result.payload
assert payload["cpu"]["usage_pct"] == pytest.approx(30.0, abs=1.0)
assert payload["name"] == "driveripper"
assert payload["cpu"]["usage_pct"] is not None
assert payload["memory"]["total_bytes"] > 0
# real captures have real GPUs; the legend wants the short name, the tooltip the full one
assert payload["gpus"], "driveripper's capture must yield at least one GPU"
assert payload["gpus"][0]["model"] and "model_full" in payload["gpus"][0]
assert any(s.metric == "cpu.usage_pct" for s in result.samples)
async def test_target_headers_are_sent_to_the_agent() -> None:
seen: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["authorization"] = request.headers.get("authorization", "")
return httpx.Response(200, json=agent_body())
target = Target("gateway", "http://agent.test", headers={"Authorization": "Bearer tok"})
async with client_for(handler) as client:
await fetch_host_snapshot(client, target, HostCollector(RateEstimator()))
assert seen["authorization"] == "Bearer tok"
@pytest.mark.parametrize("status,fragment", [
(401, "token"),
(403, "HTTP 403"),
(500, "HTTP 500"),
])
async def test_agent_rejections_say_which_one_it_was(status, fragment) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status, text="nope")
async with client_for(handler) as client:
with pytest.raises(UpstreamError) as excinfo:
await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
assert fragment in excinfo.value.message
assert excinfo.value.status == 502
async def test_a_refused_connection_is_an_upstream_error_not_a_traceback() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
async with client_for(handler) as client:
with pytest.raises(UpstreamError, match="unreachable"):
await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
async def test_a_slow_agent_is_a_gateway_timeout() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("too slow")
async with client_for(handler) as client:
with pytest.raises(UpstreamError) as excinfo:
await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
assert excinfo.value.status == 504
async def test_html_instead_of_json_is_reported_as_such() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<html><body>502 Bad Gateway</body></html>")
async with client_for(handler) as client:
with pytest.raises(UpstreamError, match="non-JSON"):
await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
async def test_an_agent_answering_not_ok_is_not_silently_empty() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": False, "detail": "procfs unreadable"})
async with client_for(handler) as client:
with pytest.raises(UpstreamError, match="procfs unreadable"):
await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
async def test_a_degraded_agent_still_renders_and_says_what_broke() -> None:
body = agent_body()
body["errors"] = {"temperatures": "no hwmon sensors"}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=body)
async with client_for(handler) as client:
result = await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
assert result.payload["degraded"] == {"temperatures": "no hwmon sensors"}
assert result.payload["memory"]["total_bytes"] > 0
# --------------------------------------------------------------------------- #
# LLM engines
# --------------------------------------------------------------------------- #
GUFO = (FIXTURES / "metrics_gufo.txt").read_text()
VLLM = (FIXTURES / "metrics_vllm.txt").read_text()
def bump(text: str, series: str, amount: float) -> str:
lines = []
for line in text.splitlines():
if line.startswith(series + " "):
head, _, value = line.rpartition(" ")
line = f"{head} {float(value) + amount}"
lines.append(line)
return "\n".join(lines) + "\n"
async def test_first_gufo_poll_has_no_measured_rate_yet() -> None:
"""One scrape cannot produce Δ/Δt. The cell is n/a, not a stale single-request gauge."""
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/metrics":
return httpx.Response(200, text=GUFO)
return httpx.Response(404, text="nope")
async with client_for(handler) as client:
result = await fetch_engine(client, "gufo", "http://engine.test", "", RateEstimator(),
with_slots=True)
decode = result.payload["metrics"]["decode_tokens_per_second"]
assert decode["value"] is None
assert decode["source"] is None
assert result.payload["sessions"]["active"] == 0.0
assert "no /slots answer" in result.payload["sessions"]["detail"]
async def test_second_gufo_poll_measures_throughput_over_a_window() -> None:
state = {"text": GUFO}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/metrics":
return httpx.Response(200, text=state["text"])
if request.url.path == "/slots":
return httpx.Response(200, json=[{"index": 0, "n_ctx": 8192, "predicted": 12}])
return httpx.Response(404)
rates = RateEstimator(span=10.0)
async with client_for(handler) as client:
await fetch_engine(client, "gufo", "http://engine.test", "", rates)
state["text"] = bump(GUFO, "llamacpp:tokens_predicted_total", 40.0)
result = await fetch_engine(client, "gufo", "http://engine.test", "", rates,
with_slots=True)
decode = result.payload["metrics"]["decode_tokens_per_second"]
assert decode["source"].startswith("computed:Δllamacpp:")
assert decode["derived"] is True
assert decode["window"] > 0
assert decode["value"] == pytest.approx(40.0 / decode["window"], rel=0.05)
assert result.payload["sessions"]["source"] == "gufo /slots"
assert result.payload["sessions"]["list"][0]["index"] == 0
async def test_gufo_slots_alias_is_used_when_plain_slots_is_missing() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/metrics":
return httpx.Response(200, text=GUFO)
if request.url.path == "/v1/slots":
return httpx.Response(200, json={"slots": [{"index": 1}]})
return httpx.Response(404)
async with client_for(handler) as client:
result = await fetch_engine(client, "gufo", "http://engine.test", "", RateEstimator(),
with_slots=True)
assert result.payload["sessions"]["list"] == [{"index": 1}]
async def test_vllm_metrics_parse_and_carry_the_api_key() -> None:
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers.get("authorization", ""))
return httpx.Response(200, text=VLLM)
async with client_for(handler) as client:
result = await fetch_engine(client, "vllm", "http://engine.test", "SECRET",
RateEstimator())
assert seen == ["Bearer SECRET"]
metrics = result.payload["metrics"]
assert metrics["num_requests_running"]["value"] is not None
# vLLM publishes no per-session list: the page is told, rather than shown an empty table
assert "counts, not sessions" in result.payload["sessions"]["detail"]
assert any(s.metric.startswith("llm.") for s in result.samples)
@pytest.mark.parametrize("status", [404, 500, 503])
async def test_engine_http_errors_name_the_status(status) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status, text="")
async with client_for(handler) as client:
with pytest.raises(UpstreamError, match=f"HTTP {status}"):
await fetch_engine(client, "vllm", "http://engine.test", "", RateEstimator())
async def test_engine_timeout_is_504() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("engine busy")
async with client_for(handler) as client:
with pytest.raises(UpstreamError) as excinfo:
await fetch_engine(client, "gufo", "http://engine.test", "", RateEstimator())
assert excinfo.value.status == 504
# --------------------------------------------------------------------------- #
# NUT monitor
# --------------------------------------------------------------------------- #
class FakeSimulator:
"""Stands in for NutSimulator: hands back readings we control."""
def __init__(self, readings: list[nut.UpsReading]) -> None:
self.readings = readings
async def read(self) -> list[nut.UpsReading]:
return list(self.readings)
def reading(status: str, **vars: str) -> nut.UpsReading:
item = nut.UpsReading(name="cyberpower", description="CP1350PFCLCD", ts=1_700_000_000.0)
item.vars = {"ups.status": status, "battery.charge": "92", "ups.load": "35",
"battery.runtime": "1800", **vars}
item.commands = ["beeper.mute", "test.panel.start", "test.battery.start.deep"]
return item
class SettingsStub:
nut_host, nut_port, nut_timeout = "ups.test", 3493, 5.0
nut_user = nut_password = ""
nut_interval = 5.0
nut_enabled = True
actions_allowed = True
read_only = False
async def test_monitor_payload_labels_the_simulator() -> None:
monitor = NutMonitor(SettingsStub(), simulator=FakeSimulator([reading("OL")]))
result = await monitor.collect()
payload = result.payload
assert payload["simulated"] is True
assert payload["server"] is None, "a simulator must not claim a server address"
assert payload["actions_enabled"] is True
card = payload["ups"][0]
assert card["name"] == "cyberpower"
assert card["status"] == "OL"
assert card["numbers"]["charge_pct"] == 92.0
# a driver that reports no battery date, temperature, or self-test result is `unknown`,
# never a silent "healthy" — and never a failure either
assert card["health"]["overall"] == "unknown", card["health"]
assert card["health"]["counts"]["fail"] == 0
assert card["health"]["counts"]["warn"] == 0
# commands are offered with their risk, and the dangerous one is present but marked
risks = {item["command"]: item for item in card["commands"]}
assert risks["beeper.mute"]["enabled"] is True
assert risks["test.battery.start.deep"]["risk"] == "dangerous"
async def test_a_fully_report_ups_is_called_healthy() -> None:
full = reading("OL", **{"battery.temperature": "27.4", "battery.mfr.date": "2024-06-01",
"battery.test.result": "OK", "input.voltage": "230"})
full.vars["battery.charge"] = "100"
card = (await NutMonitor(SettingsStub(), simulator=FakeSimulator([full])).collect()).payload["ups"][0]
assert card["health"]["overall"] == "healthy", card["health"]
assert card["health"]["counts"]["unknown"] == 0
async def test_monitor_sends_history_samples_for_every_ups_metric() -> None:
monitor = NutMonitor(SettingsStub(), simulator=FakeSimulator([reading("OB")]))
result = await monitor.collect()
names = {sample.metric for sample in result.samples}
assert "ups.charge_pct" in names
assert "ups.load_pct" in names
assert all(sample.source == "ups" for sample in result.samples)
async def test_status_changes_become_one_event_each() -> None:
monitor = NutMonitor(SettingsStub(), simulator=FakeSimulator([reading("OL")]))
assert (await monitor.collect()).events == []
monitor.simulator = FakeSimulator([reading("OL")])
assert (await monitor.collect()).events == [], "an unchanged status is not news"
monitor.simulator = FakeSimulator([reading("OB")])
events = (await monitor.collect()).events
assert len(events) == 1
assert events[0].kind == "status"
assert "OL → OB" in events[0].message
assert events[0].severity == "warn", "on battery is a warning, not a failure"
monitor.simulator = FakeSimulator([reading("OL FUB")])
events = (await monitor.collect()).events
assert events[0].severity == "error", "FUB (the UPS decided to power off) is a failure"
async def test_a_self_test_result_is_reported_when_it_changes() -> None:
monitor = NutMonitor(SettingsStub(), simulator=FakeSimulator([reading("OL")]))
await monitor.collect()
# the variable is `ups.test.result` (drivers also use battery.test.result) — see VAR_MAP
monitor.simulator = FakeSimulator([reading("OL", **{"ups.test.result": "INPROGRESS"})])
assert (await monitor.collect()).events == [], "first sighting is not news"
monitor.simulator = FakeSimulator([reading("OL", **{"ups.test.result": "FAIL"})])
events = (await monitor.collect()).events
assert [event.kind for event in events] == ["test"]
assert events[0].severity == "warn"
monitor.simulator = FakeSimulator([reading("OL", **{"ups.test.result": "OK"})])
assert (await monitor.collect()).events[0].severity == "info"
async def test_gating_is_applied_where_the_commands_are_built() -> None:
class ReadOnly(SettingsStub):
actions_allowed = False
monitor = NutMonitor(ReadOnly(), simulator=FakeSimulator([reading("OL")]))
card = (await monitor.collect()).payload["ups"][0]
assert all(item["enabled"] is False for item in card["commands"])
assert all(item["reason"] for item in card["commands"]), "every refusal must explain itself"
assert set(card["advertised_commands"]) == {"beeper.mute", "test.panel.start",
"test.battery.start.deep"}, (
"the driver still advertises them — the refusal is ours, and must say so")
class FakeNutClient:
"""The real upsd branch without a socket: vars work, the optional lists do not."""
instances: ClassVar[list[FakeNutClient]] = []
def __init__(self, host: str, port: int, **kwargs: Any) -> None:
self.host, self.port, self.kwargs = host, port, kwargs
self.entered = False
FakeNutClient.instances.append(self)
async def __aenter__(self) -> FakeNutClient:
self.entered = True
return self
async def __aexit__(self, *exc: Any) -> None:
self.entered = False
async def list_ups(self) -> list[dict[str, str]]:
return [{"name": "cyberpower", "description": "CP1350PFCLCD"}]
async def list_vars(self, ups: str) -> dict[str, str]:
return {"ups.status": "OL", "battery.charge": "100", "battery.temperature": "301.1"}
async def list_cmds(self, ups: str) -> list[str]:
raise nut.NutError("this driver exports no commands")
async def list_rw_vars(self, ups: str) -> list[str]:
raise nut.NutError("LIST RW denied")
async def test_real_upsd_branch_survives_drivers_that_refuse_the_optional_lists(
monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(nut, "NutClient", FakeNutClient)
monitor = NutMonitor(SettingsStub())
result = await monitor.collect()
client = FakeNutClient.instances[-1]
assert client.entered is False, "the connection must not outlive one poll"
assert client.kwargs["timeout"] == 5.0
card = result.payload["ups"][0]
assert card["commands"] == [] and card["advertised_commands"] == []
assert card["numbers"]["charge_pct"] == 100.0
# a driver reporting Kelvin is normalised, not charted as 301 °C
assert card["numbers"]["battery_temperature"] == pytest.approx(301.1 - 273.15, abs=0.01)
samples = {sample.metric: sample.value for sample in result.samples}
assert samples["ups.battery_temperature_c"] == pytest.approx(27.95, abs=0.01)
async def test_a_real_server_names_itself_in_the_payload(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(nut, "NutClient", FakeNutClient)
payload = (await NutMonitor(SettingsStub()).collect()).payload
assert payload["simulated"] is False
assert payload["server"] == "ups.test:3493"
+181
View File
@@ -0,0 +1,181 @@
"""Configuration: env-only, no defaults that point at somebody else's machine."""
from __future__ import annotations
import importlib
import os
from dataclasses import FrozenInstanceError
from typing import Any
import pytest
from dashboards import config
from dashboards.config import Settings, parse_targets
def test_parses_whitespace_and_comma_separated_targets() -> None:
targets, problems = parse_targets("a=http://h1:9120 b=http://h2:9120,c=http://h3:9120")
assert [t.name for t in targets] == ["a", "b", "c"]
assert [t.url for t in targets] == ["http://h1:9120", "http://h2:9120", "http://h3:9120"]
assert problems == []
def test_trailing_slash_is_stripped_so_urls_do_not_double_up() -> None:
targets, _ = parse_targets("a=http://h1:9120/")
assert targets[0].url == "http://h1:9120"
def test_one_bad_entry_does_not_sink_the_others() -> None:
targets, problems = parse_targets("good=http://h:1 broken-no-equals another=ftp://h:1")
assert [t.name for t in targets] == ["good"]
assert len(problems) == 2
assert any("not name=url" in p for p in problems)
assert any("http://" in p for p in problems)
def test_empty_input_yields_no_targets_and_no_noise() -> None:
assert parse_targets("") == ([], [])
assert parse_targets(" \n ") == ([], [])
def test_token_in_url_becomes_a_bearer_header_and_stays_out_of_the_display_url() -> None:
targets, problems = parse_targets("driveripper=http://SEKRIT@driveripper:9120")
assert problems == []
target = targets[0]
assert target.headers == {"Authorization": "Bearer SEKRIT"}
assert target.url == "http://driveripper:9120", "the token must not stay in the URL"
assert target.display_url == "http://driveripper:9120"
assert "SEKRIT" not in target.display_url
def test_target_names_are_restricted_because_they_are_db_keys() -> None:
"""Names key the history DB and appear in URLs, so they are validated, not guessed.
Whitespace separates entries, which means "Bad Name=http://h" is two entries — both
reported, because the operator needs to see exactly what was rejected.
"""
targets, problems = parse_targets("Bad=http://h:1 /=http://h:1 ok-1_2=http://h:1")
assert [t.name for t in targets] == ["ok-1_2"]
assert len(problems) == 2
assert any("Bad" in p for p in problems)
_targets, problems = parse_targets("Bad Name=http://h:1")
assert len(problems) == 2, "a space splits the entry, so both halves are rejected"
def test_settings_default_to_no_sources_configured() -> None:
settings = Settings()
assert settings.host_targets == ()
assert settings.gufo_url == "" and settings.vllm_url == ""
assert settings.nut_host == ""
assert settings.nut_enabled is False
assert settings.any_source_enabled is False
def test_actions_need_the_flag_and_are_dead_under_read_only() -> None:
assert Settings(nut_allow_actions=True, read_only=False).actions_allowed is True
assert Settings(nut_allow_actions=True, read_only=True).actions_allowed is False
assert Settings(nut_allow_actions=False, read_only=False).actions_allowed is False
def test_nut_host_accepts_a_nut_scheme_prefix() -> None:
assert Settings(nut_host="nut://ups-box:3493").nut_enabled is True
def test_load_reads_host_targets_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HOST_TARGETS", "deskwork=http://127.0.0.1:9122")
settings = config.load()
assert [t.name for t in settings.host_targets] == ["deskwork"]
def test_env_driven_scalars_are_read_at_import(monkeypatch: pytest.MonkeyPatch) -> None:
"""The dataclass defaults read os.environ, so a reload must pick up a changed env."""
monkeypatch.setenv("DASH_PORT", "8123")
monkeypatch.setenv("RETAIN_RAW_HOURS", "6")
monkeypatch.setenv("NUT_ALLOW_ACTIONS", "1")
reloaded = importlib.reload(config)
try:
assert reloaded.SETTINGS.port == 8123
assert reloaded.SETTINGS.raw_keep_hours == 6
assert reloaded.SETTINGS.actions_allowed is True
finally:
monkeypatch.delenv("DASH_PORT")
monkeypatch.delenv("RETAIN_RAW_HOURS")
monkeypatch.delenv("NUT_ALLOW_ACTIONS")
importlib.reload(config)
def test_flags_accept_the_usual_spellings(monkeypatch: pytest.MonkeyPatch) -> None:
for truthy in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("DASH_READ_ONLY", truthy)
assert config._flag("DASH_READ_ONLY") is True, truthy
for falsey in ("0", "false", "", "no"):
monkeypatch.setenv("DASH_READ_ONLY", falsey)
assert config._flag("DASH_READ_ONLY") is False, falsey
def test_numeric_env_must_be_a_number_or_the_default_wins(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HOST_INTERVAL_SECONDS", "not-a-number")
assert config._num("HOST_INTERVAL_SECONDS", 10.0) == 10.0
monkeypatch.setenv("HOST_INTERVAL_SECONDS", "2.5")
assert config._num("HOST_INTERVAL_SECONDS", 10.0) == 2.5
def test_settings_is_immutable_so_nobody_mutates_shared_state() -> None:
settings: Any = Settings()
with pytest.raises(FrozenInstanceError):
settings.port = 1 # type: ignore[misc]
# ---- the .env convenience -------------------------------------------------- #
def test_no_env_file_is_the_normal_case(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
monkeypatch.setenv("DASH_ENV_FILE", str(tmp_path / "absent.env"))
assert config.load_env_file() is None
def test_env_file_seeds_values_a_deployed_env_does_not_set(monkeypatch: pytest.MonkeyPatch,
tmp_path: Any) -> None:
env = tmp_path / ".env"
env.write_text("DASH_PORT=9999\nNUT_HOST=ups.internal\n")
monkeypatch.setenv("DASH_ENV_FILE", str(env))
monkeypatch.delenv("DASH_PORT", raising=False)
monkeypatch.delenv("NUT_HOST", raising=False)
assert config.load_env_file() == env
# Settings reads its defaults when the module is imported, so the reload is what a real
# process gets for free and what the test has to ask for explicitly
reloaded = importlib.reload(config)
assert reloaded.SETTINGS.port == 9999
assert reloaded.SETTINGS.nut_host == "ups.internal"
def test_a_real_environment_variable_beats_the_file(monkeypatch: pytest.MonkeyPatch,
tmp_path: Any) -> None:
"""The container/quadlet env is the source of truth; the file is for laptops."""
env = tmp_path / ".env"
env.write_text("DASH_PORT=9999\n")
monkeypatch.setenv("DASH_ENV_FILE", str(env))
monkeypatch.setenv("DASH_PORT", "7777")
reloaded = importlib.reload(config)
assert os.environ["DASH_PORT"] == "7777"
assert reloaded.SETTINGS.port == 7777
def test_env_file_expands_other_variables(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
"""`.env.example` shows `DASH_DB=${HOME}/...` — the file has to mean what it shows."""
env = tmp_path / ".env"
env.write_text("MY_BASE=/srv/dash\nDASH_DB=${MY_BASE}/dashboards.sqlite3\n")
monkeypatch.setenv("DASH_ENV_FILE", str(env))
monkeypatch.delenv("MY_BASE", raising=False)
monkeypatch.delenv("DASH_DB", raising=False)
reloaded = importlib.reload(config)
assert reloaded.SETTINGS.db_path == "/srv/dash/dashboards.sqlite3"
def test_the_example_file_is_never_loaded(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
example = tmp_path / ".env.example"
example.write_text("NUT_HOST=should-not-load\n")
monkeypatch.setenv("DASH_ENV_FILE", str(example))
monkeypatch.delenv("NUT_HOST", raising=False)
reloaded = importlib.reload(config)
assert reloaded.load_env_file() is None
assert reloaded.SETTINGS.nut_host == ""
+242
View File
@@ -0,0 +1,242 @@
"""Agent snapshot -> dashboard payload: rates, null-safety, and honest fleet totals.
The rule this file defends hardest is *null in, null out*. A missing sensor rendered as 0
is a dashboard that says "the GPU is idle" when it means "the driver said nothing", and on
a machine you are debugging at 2am that difference is the whole point.
"""
from __future__ import annotations
import copy
import math
from typing import Any
import pytest
from dashboards.rates import RateEstimator
from dashboards.sources.host import HostCollector, fleet_summary, parse_host_snapshot
TICKS_PER_SECOND = 100.0
def make_collector() -> HostCollector:
return HostCollector(RateEstimator(span=60.0))
def advance(snapshot: dict[str, Any], dt: float = 10.0, *, total_ticks: float = 1000.0,
idle_ticks: float = 900.0, read_bytes: float = 0.0, write_bytes: float = 0.0,
rx_bytes: float = 0.0, tx_bytes: float = 0.0) -> dict[str, Any]:
"""A second scrape `dt` later with counters moved by known amounts."""
snap = copy.deepcopy(snapshot)
snap["ts"] = float(snapshot["ts"]) + dt
modes = snap["cpu"]["modes"]
idle_parts = {"idle": idle_ticks * 0.9, "iowait": idle_ticks * 0.1}
for key, value in modes.items():
if key == "total":
modes["total"] = value + total_ticks
elif key in idle_parts:
modes[key] = value + idle_parts[key]
elif key != "guest" and key != "guest_nice":
modes[key] = value + (total_ticks - idle_ticks) / 5
for device in snap["disk_io"]["devices"]:
device["read_bytes_total"] += read_bytes
device["write_bytes_total"] += write_bytes
device["reads_total"] += read_bytes / 4096
device["writes_total"] += write_bytes / 4096
for iface in snap["network"]["interfaces"]:
iface["rx_bytes_total"] += rx_bytes
iface["tx_bytes_total"] += tx_bytes
return snap
def parse_twice(agent_snapshot) -> tuple[dict[str, Any], dict[str, Any]]:
collector = make_collector()
first = parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0)
second_snap = advance(agent_snapshot, 10.0, read_bytes=4_096_000, write_bytes=2_048_000,
rx_bytes=100_000, tx_bytes=50_000)
second = parse_host_snapshot("driveripper", second_snap, collector, now=1_010.0)
return first.payload, second.payload
def test_first_scrape_has_no_rates_but_reports_instantaneous_values(agent_snapshot) -> None:
collector = make_collector()
payload = parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0).payload
assert payload["cpu"]["usage_pct"] is None, "one sample cannot make a rate"
assert payload["cpu"]["load1"] is not None, "load is a gauge, available immediately"
assert payload["memory"]["used_pct"] is not None
assert payload["disks"]["read_bytes_per_s"] is None
assert payload["network"]["rx_bytes_per_s"] is None
assert payload["window_seconds"] is None
def test_second_scrape_measures_the_window_it_claims(agent_snapshot) -> None:
"""Two scrapes are enough — a dashboard that needs three looks to say anything about
CPU is a dashboard whose first minute of history is a hole."""
_first, payload = parse_twice(agent_snapshot)
assert payload["window_seconds"] == 10.0
usage = payload["cpu"]["usage_pct"]
assert usage is not None
# 1000 ticks total, 900 idle over 10s => exactly 10% busy, whatever the host was doing
assert usage == 10.0
def test_iowait_counts_as_idle_but_is_still_reported(agent_snapshot) -> None:
_first, payload = parse_twice(agent_snapshot)
modes = payload["cpu"]["modes_pct"]
assert modes["iowait"] is not None and modes["iowait"] > 0
assert sum(v for v in modes.values() if v is not None) <= 100.0001
def test_per_core_series_is_one_point_per_logical_cpu(agent_snapshot) -> None:
collector = make_collector()
parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0)
payload = parse_host_snapshot("driveripper", advance(agent_snapshot, 10.0), collector,
now=1_010.0).payload
cores = payload["cpu"]["core_usage_pct"]
assert len(cores) == payload["info"]["cpu_cores_logical"] == 48
assert all(value is None or 0.0 <= value <= 100.0 for value in cores)
def test_disk_and_network_rates_are_bytes_per_second_over_the_window(agent_snapshot) -> None:
_first, payload = parse_twice(agent_snapshot)
devices = payload["disks"]["devices"]
assert devices, "captured host has block devices"
# the fixture scrape added the same amount to every device/interface, so the per-device
# figure is delta/dt and the headline total is the sum of what the payload shows
assert devices[0]["read_bytes_per_s"] == 4_096_000 / 10.0
assert payload["disks"]["read_bytes_per_s"] == sum(
d["read_bytes_per_s"] or 0.0 for d in devices)
assert payload["disks"]["write_bytes_per_s"] == sum(
d["write_bytes_per_s"] or 0.0 for d in devices)
interfaces = payload["network"]["interfaces"]
assert payload["network"]["rx_bytes_per_s"] == 100_000 / 10.0 * len(interfaces)
assert payload["network"]["tx_bytes_per_s"] == 50_000 / 10.0 * len(interfaces)
def test_idle_machine_reports_zero_not_none_once_measured(agent_snapshot) -> None:
"""0 and n/a are different claims: 'nothing happened' vs 'we could not tell'."""
collector = make_collector()
parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0)
quiet = advance(agent_snapshot, 10.0, total_ticks=1_000.0, idle_ticks=1_000.0)
payload = parse_host_snapshot("driveripper", quiet, collector, now=1_010.0).payload
assert payload["cpu"]["usage_pct"] == 0.0
assert payload["disks"]["read_bytes_per_s"] == 0.0
assert payload["network"]["rx_bytes_per_s"] == 0.0
def test_missing_sensors_stay_missing(agent_snapshot) -> None:
snap = copy.deepcopy(agent_snapshot)
snap["temperatures"] = [] # the agent reports a flat sensor list
snap["cpu_temperature_celsius"] = None # ...plus its own pick for "the CPU temp"
snap["cpu"]["freq_mhz"] = None
for gpu in snap["gpus"]:
gpu["power_watts"] = None
gpu["temps"] = {}
collector = make_collector()
payload = parse_host_snapshot("driveripper", snap, collector, now=1_000.0).payload
assert payload["cpu"]["temp_c"] is None
assert payload["cpu"]["freq_mhz"] is None
assert payload["temperatures"]["max_c"] is None
assert payload["temperatures"]["sensors"] == []
for gpu in payload["gpus"]:
assert gpu["power_watts"] is None
assert gpu["temps"] == {}
assert gpu["temp_c"] is None
def test_no_sample_is_ever_written_as_zero_for_a_missing_value(agent_snapshot) -> None:
snap = copy.deepcopy(agent_snapshot)
snap["cpu"]["freq_mhz"] = None
snap["gpus"] = []
collector = make_collector()
result = parse_host_snapshot("driveripper", snap, collector, now=1_000.0)
for sample in result.samples:
assert sample.value is not None
assert math.isfinite(sample.value), f"{sample.metric} is {sample.value}"
assert not any(s.metric == "cpu.freq_mhz" for s in result.samples)
def test_gpu_pool_percent_accounts_for_vram_and_gtt(agent_snapshot) -> None:
"""On an APU the model lives in GTT; reporting only vram says '32 MB used' on a 128 GB box."""
collector = make_collector()
payload = parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0).payload
gpu = payload["gpus"][0]
assert gpu["model_full"] and gpu["model"]
assert gpu["vram_total_bytes"] > 0
assert gpu["mem_used_pct"] is not None and 0.0 <= gpu["mem_used_pct"] <= 100.0
assert "gtt_total_bytes" in gpu
def test_reboot_is_reported_as_an_event(agent_snapshot) -> None:
collector = make_collector()
parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0)
snap = advance(agent_snapshot, 10.0)
snap["host"]["boot_ts"] = float(agent_snapshot["host"]["boot_ts"]) + 5_000
result = parse_host_snapshot("driveripper", snap, collector, now=1_010.0)
reboots = [e for e in result.events if e.kind == "reboot"]
assert len(reboots) == 1
assert reboots[0].severity == "warn"
assert "rebooted" in reboots[0].message
def test_agent_errors_travel_to_the_ui(agent_snapshot) -> None:
snap = copy.deepcopy(agent_snapshot)
snap["errors"] = ["disk_io: permission denied reading /sys/block/sdb"]
payload = parse_host_snapshot("driveripper", snap, make_collector(), now=1_000.0).payload
assert payload["agent"]["errors"], "a partial snapshot must be labelled partial"
def test_counter_reset_from_a_reboot_does_not_chart_a_spike(agent_snapshot) -> None:
collector = make_collector()
parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0)
snap = copy.deepcopy(agent_snapshot)
snap["cpu"]["modes"]["total"] = 1.0 # counter restarted from zero
payload = parse_host_snapshot("driveripper", snap, collector, now=1_010.0).payload
assert payload["cpu"]["usage_pct"] is None
def test_fleet_summary_ignores_hosts_that_are_not_answering(agent_snapshot) -> None:
payload = parse_host_snapshot("driveripper", agent_snapshot, make_collector(),
now=1_000.0).payload
live = copy.deepcopy(payload)
live["cpu"]["usage_pct"] = 40.0
live["info"]["cpu_cores_logical"] = 48
dead = copy.deepcopy(payload)
dead["name"] = "deskwork"
dead["cpu"]["usage_pct"] = None
fleet = fleet_summary([live, dead])
assert fleet["hosts_total"] == 2
assert fleet["hosts_live"] == 1
assert fleet["cpu_usage_pct_avg"] == 40.0, "a dead host must not drag the average to 0"
def test_fleet_summary_weights_cpu_by_cores(agent_snapshot) -> None:
base = parse_host_snapshot("driveripper", agent_snapshot, make_collector(),
now=1_000.0).payload
big = copy.deepcopy(base)
big["cpu"]["usage_pct"] = 10.0
big["info"]["cpu_cores_logical"] = 100
small = copy.deepcopy(base)
small["name"] = "gateway"
small["cpu"]["usage_pct"] = 90.0
small["info"]["cpu_cores_logical"] = 2
fleet = fleet_summary([big, small])
assert fleet["cpu_usage_pct_avg"] == pytest.approx((10 * 100 + 90 * 2) / 102)
assert fleet["cpu_cores"] == 102
def test_fleet_summary_worst_disk_and_memory_totals(agent_snapshot) -> None:
base = parse_host_snapshot("driveripper", agent_snapshot, make_collector(),
now=1_000.0).payload
fleet = fleet_summary([base, base])
assert fleet["disk_worst_used_pct"] == pytest.approx(base["disks"]["worst_used_pct"])
assert fleet["memory_total_bytes"] == pytest.approx(2 * base["memory"]["total_bytes"])
assert fleet["memory_used_pct"] == pytest.approx(
base["memory"]["used_bytes"] / base["memory"]["total_bytes"] * 100)
def test_fleet_summary_of_nothing_is_empty_not_zero() -> None:
fleet = fleet_summary([])
assert fleet["hosts_total"] == 0
assert fleet["cpu_usage_pct_avg"] is None
assert fleet["memory_used_bytes"] is None
+267
View File
@@ -0,0 +1,267 @@
"""LLM engine parsing: real captures, provenance on every number, absent never 0.
The engines do not export the names people expect, so these tests are pinned to captures of
the deployed servers (tests/fixtures/metrics_*.txt), not to upstream documentation.
"""
from __future__ import annotations
import re
import pytest
from dashboards.rates import RateEstimator
from dashboards.sources.llm import (
engine_result,
fleet_totals,
parse_gufo_metrics,
parse_vllm_metrics,
series_names,
)
def bump_metric(text: str, name: str, delta: float) -> str:
"""Return the exposition with one counter increased — a scrape `dt` later."""
pattern = re.compile(rf"^({re.escape(name)}(?:\{{[^}}]*\}})?) (\S+)$", re.M)
def replace(match: re.Match[str]) -> str:
return f"{match.group(1)} {float(match.group(2)) + delta}"
bumped, count = pattern.subn(replace, text)
assert count, f"{name} not present in the capture"
return bumped
# ---- gufo / llama.cpp ----------------------------------------------------- #
def test_gufo_counters_come_straight_from_the_capture(metrics_text) -> None:
metrics = parse_gufo_metrics(metrics_text("gufo"))
assert metrics["tokens_generated_total"]["value"] == 877_336.0
assert metrics["tokens_prompt_total"]["value"] == 1_350_666.0
assert metrics["tokens_prompt_cached_total"]["value"] == 124_529_105.0
assert metrics["requests_processing"]["value"] == 0.0
assert metrics["device_lost_total"]["value"] == 0.0
assert metrics["kv_cache_usage_ratio"]["value"] == 0.0
def test_gufo_speculative_accept_rate_is_accepted_over_draft(metrics_text) -> None:
metrics = parse_gufo_metrics(metrics_text("gufo"))
accept = metrics["spec_accept_rate"]["value"]
assert accept == pytest.approx(578_514 / 789_028)
assert str(metrics["spec_accept_rate"]["source"]).startswith("computed")
def test_gufo_lifetime_tokens_include_cache_hits(metrics_text) -> None:
"""gufo's prompt counter excludes cache hits; the lifetime total must not."""
metrics = parse_gufo_metrics(metrics_text("gufo"))
total = metrics["tokens_total"]["value"]
assert total == 1_350_666 + 124_529_105 + 877_336
def test_gufo_throughput_prefers_measured_delta_over_the_native_gauge(metrics_text) -> None:
"""llamacpp:predicted_tokens_seconds is ONE request's last pass — never the headline."""
text = metrics_text("gufo")
rates = RateEstimator(span=10)
now = 1_000.0
first = parse_gufo_metrics(text, rates, now)
assert "decode_tokens_per_second" not in first or first["decode_tokens_per_second"]["value"] is None
text = bump_metric(text, "llamacpp:tokens_predicted_total", 400.0)
metrics = parse_gufo_metrics(text, rates, now + 10.0)
decode = metrics["decode_tokens_per_second"]
assert decode["value"] == pytest.approx(40.0)
assert decode["window"] == pytest.approx(10.0)
assert "Δ" in decode["source"] and "tokens_predicted_total" in decode["source"]
# the native gauge is still there, labelled, because it answers a different question
assert metrics["decode_tokens_per_second_native"]["value"] == pytest.approx(42.621031668072597)
def test_gufo_prompt_rate_excludes_cached_tokens(metrics_text) -> None:
text = metrics_text("gufo")
rates = RateEstimator(span=10)
parse_gufo_metrics(text, rates, 1_000.0)
text = bump_metric(text, "llamacpp:prompt_tokens_total", 1_000.0)
metrics = parse_gufo_metrics(text, rates, 1_010.0)
assert metrics["prompt_tokens_per_second"]["value"] == pytest.approx(100.0)
def test_gufo_counter_reset_produces_n_a_not_a_spike(metrics_text) -> None:
text = metrics_text("gufo")
rates = RateEstimator(span=60)
parse_gufo_metrics(text, rates, 1_000.0)
restarted = bump_metric(text, "llamacpp:tokens_predicted_total", -800_000.0)
metrics = parse_gufo_metrics(restarted, rates, 1_010.0)
assert metrics.get("decode_tokens_per_second", {}).get("value") is None
def test_absent_gufo_metrics_are_none_with_no_source() -> None:
metrics = parse_gufo_metrics("# nothing here\n")
assert metrics["requests_processing"]["value"] is None
assert metrics["requests_processing"]["source"] is None
assert metrics.get("spec_accept_rate", {}).get("value") is None
# ---- vLLM ----------------------------------------------------------------- #
def test_vllm_counters_and_gauges(metrics_text) -> None:
metrics = parse_vllm_metrics(metrics_text("vllm"))
assert metrics["prompt_tokens_total"]["value"] == pytest.approx(1.8389909e7)
assert metrics["generation_tokens_total"]["value"] == pytest.approx(611_163.0)
usage = metrics["gpu_cache_usage"]["value"]
assert usage is None or 0.0 <= usage <= 1.0, "a ratio summed over label sets would exceed 1"
assert metrics["num_requests_running"]["value"] is not None
assert metrics["num_preemptions_total"]["value"] is not None
def test_vllm_latency_histograms(metrics_text) -> None:
metrics = parse_vllm_metrics(metrics_text("vllm"))
ttft = metrics["time_to_first_token_seconds"]
assert ttft and ttft["count"] > 0 and ttft["mean"] > 0
assert metrics["time_to_first_token_seconds__p95"]["value"] >= ttft["mean"]
assert metrics["time_per_output_token_seconds"]["mean"] > 0
def test_vllm_per_request_speed_is_the_reciprocal_of_tpot(metrics_text) -> None:
metrics = parse_vllm_metrics(metrics_text("vllm"))
tpot = metrics["time_per_output_token_seconds"]["mean"]
per_request = metrics["per_request_decode_tokens_per_second"]
assert per_request["value"] == pytest.approx(1.0 / tpot)
assert per_request["derived"] is True
def test_vllm_prefix_cache_hit_ratio(metrics_text) -> None:
metrics = parse_vllm_metrics(metrics_text("vllm"))
ratio = metrics["prefix_cache_hit_ratio"]["value"]
assert ratio is not None and 0.0 <= ratio <= 1.0
assert "hits" in metrics["prefix_cache_hit_ratio"]["source"]
def test_vllm_measured_rate_when_the_build_has_no_throughput_gauge(metrics_text) -> None:
text = metrics_text("vllm")
rates = RateEstimator(span=10)
parse_vllm_metrics(text, rates, 2_000.0)
text = bump_metric(text, "vllm:generation_tokens_total", 250.0)
metrics = parse_vllm_metrics(text, rates, 2_010.0)
assert metrics["decode_tokens_per_second"]["value"] == pytest.approx(25.0)
def test_vllm_waiting_requests_are_broken_down_by_reason(metrics_text) -> None:
metrics = parse_vllm_metrics(metrics_text("vllm"))
reasons = metrics["waiting_by_reason"]["value"]
assert isinstance(reasons, dict)
def test_vllm_empty_exposition_is_all_none() -> None:
metrics = parse_vllm_metrics("")
assert metrics["num_requests_running"]["value"] is None
assert metrics["time_to_first_token_seconds"] is None
assert metrics["per_request_decode_tokens_per_second"]["value"] is None
# ---- Result assembly ------------------------------------------------------ #
def test_engine_result_payload_and_samples(metrics_text) -> None:
rates = RateEstimator(span=10)
result = engine_result("gufo", "http://gufo.internal:8731", metrics_text("gufo"), rates,
now=3_000.0)
payload = result.payload
assert payload["engine"] == "gufo"
assert payload["upstream"] == "http://gufo.internal:8731"
assert payload["series_count"] > 10
assert payload["unparsed"] == 0
assert payload["sessions"]["detail"], "a missing /slots must be explained, not blank"
for sample in result.samples:
assert sample.source == "llm" and sample.host == "gufo"
assert sample.metric.startswith("llm.")
assert sample.value == sample.value and sample.value is not None # no NaN
def test_engine_result_charts_a_measured_rate_after_the_second_poll(metrics_text) -> None:
rates = RateEstimator(span=10)
engine_result("gufo", "http://g:1", metrics_text("gufo"), rates, now=3_000.0)
bumped = bump_metric(metrics_text("gufo"), "llamacpp:tokens_predicted_total", 100.0)
result = engine_result("gufo", "http://g:1", bumped, rates, now=3_010.0)
metrics = result.payload["metrics"]
assert metrics["decode_tokens_per_second"]["value"] == pytest.approx(10.0)
assert any(s.metric == "llm.decode_tokens_per_second" for s in result.samples)
def test_sessions_pass_through_slots_for_gufo() -> None:
slots = [{"id": 0, "idle": False, "n_prompt": 12, "n_predict": 40}]
result = engine_result("gufo", "http://g:1", "", RateEstimator(), slots=slots, now=1.0)
sessions = result.payload["sessions"]
assert sessions["list"] == slots
assert sessions["source"] == "gufo /slots"
def test_sessions_say_vllm_has_no_per_session_view() -> None:
result = engine_result("vllm", "http://v:1", "", RateEstimator(), now=1.0)
sessions = result.payload["sessions"]
assert sessions["list"] == []
assert "vLLM" in sessions["detail"]
# ---- fleet totals --------------------------------------------------------- #
def _engine(name: str, **values) -> dict:
metrics = {key: {"value": value} for key, value in values.items()}
return {"engine": name, "metrics": metrics}
def test_fleet_totals_sum_only_engines_that_answered() -> None:
engines = [
_engine("gufo", decode_tokens_per_second=40.0, prompt_tokens_per_second=10.0,
kv_cache_usage_ratio=0.25, requests_processing=1.0, requests_deferred=0.0,
tokens_total=100.0),
_engine("vllm", decode_tokens_per_second=10.0, prompt_tokens_per_second=5.0,
gpu_cache_usage=0.75, num_requests_running=2.0, num_requests_waiting=3.0,
tokens_total=200.0),
{}, # an engine whose poll failed
]
totals = fleet_totals(engines)
assert totals["engines_total"] == 3
assert totals["engines_live"] == 2
assert totals["decode_tokens_per_second"] == 50.0
assert totals["prompt_tokens_per_second"] == 15.0
assert totals["requests_active"] == 3.0
assert totals["requests_waiting"] == 3.0
assert totals["kv_cache_usage_max"] == 0.75, "cache pressure is a max, never a sum"
assert totals["tokens_total"] == 300.0
def test_fleet_totals_report_the_gufo_vllm_seam() -> None:
totals = fleet_totals([_engine("gufo"), _engine("vllm")])
assert totals["seams"], "the two engines count prompt tokens differently; say so"
def test_fleet_totals_of_nothing_is_none_not_zero() -> None:
totals = fleet_totals([])
assert totals["decode_tokens_per_second"] is None
assert totals["tokens_total"] is None
assert totals["engines_live"] == 0
def test_series_names_are_the_parse_catalog() -> None:
assert "tokens_generated_total" in series_names("gufo")
assert "generation_tokens_total" in series_names("vllm")
assert "generation_tokens_total" not in series_names("gufo")
def test_gufo_a_measured_zero_is_not_replaced_by_the_gauge(metrics_text) -> None:
"""`measured or gauge` made an idle engine advertise its last request's speed."""
text = metrics_text("gufo")
rates = RateEstimator(span=10)
parse_gufo_metrics(text, rates, 1_000.0)
metrics = parse_gufo_metrics(text, rates, 1_010.0) # counters unchanged: truly idle
decode = metrics["decode_tokens_per_second"]
assert decode["value"] == 0.0
assert "predicted_tokens_seconds" not in str(decode["source"]), "native gauge must not leak in"
def test_vllm_a_measured_zero_beats_the_running_estimate(metrics_text) -> None:
text = metrics_text("vllm")
rates = RateEstimator(span=10)
parse_vllm_metrics(text, rates, 2_000.0)
metrics = parse_vllm_metrics(text, rates, 2_010.0)
decode = metrics["decode_tokens_per_second"]
assert decode["value"] == 0.0
assert "running/tpot" not in str(decode["source"])
+85
View File
@@ -0,0 +1,85 @@
"""Startup wiring: `python -m dashboards` / the `dashboards` console script.
Two things are worth pinning: the reload path must hand uvicorn an import string (an
app object cannot be reloaded), and debugpy must stay completely inert unless asked.
"""
from __future__ import annotations
import sys
from types import ModuleType
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from dashboards import __main__ as entry
from dashboards import debug as debug_module
from dashboards.config import Settings
class FakeUvicorn(ModuleType):
def __init__(self) -> None:
super().__init__("uvicorn")
self.calls: list[tuple[Any, dict[str, Any]]] = []
def run(self, target: Any, **kwargs: Any) -> None:
self.calls.append((target, kwargs))
@pytest.fixture
def fake_uvicorn(monkeypatch: pytest.MonkeyPatch) -> FakeUvicorn:
module = FakeUvicorn()
monkeypatch.setitem(sys.modules, "uvicorn", module)
return module
def test_main_serves_the_assembled_app(fake_uvicorn: FakeUvicorn, tmp_path: Any,
monkeypatch: pytest.MonkeyPatch) -> None:
# Settings is frozen on purpose, so the module attribute is replaced, not poked. The DB
# path is private: a test must not open the developer's running database.
monkeypatch.setattr(entry, "SETTINGS",
Settings(host="0.0.0.0", port=8090, db_path=str(tmp_path / "main.sqlite3")))
monkeypatch.delenv("DASH_RELOAD", raising=False)
entry.main()
(target, kwargs), = fake_uvicorn.calls
assert kwargs == {"host": "0.0.0.0", "port": 8090}
# FastAPI keeps included routers in a private wrapper, so the honest check is a request
assert isinstance(target, FastAPI)
with TestClient(target) as client:
assert client.get("/api/health").status_code == 200
assert client.get("/ups").status_code == 200
def test_reload_mode_passes_an_import_string(fake_uvicorn: FakeUvicorn, tmp_path: Any,
monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(entry, "SETTINGS", Settings(db_path=str(tmp_path / "reload.sqlite3")))
# uvicorn cannot reload an object it was handed, only a module:app string
monkeypatch.setenv("DASH_RELOAD", "1")
entry.main()
(target, kwargs), = fake_uvicorn.calls
assert target == "dashboards.app:app"
assert kwargs["reload"] is True
def test_debugpy_is_inert_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DEBUGPY", raising=False)
assert debug_module.maybe_listen() is False
def test_debugpy_listens_when_asked(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, int]] = []
fake = ModuleType("debugpy")
fake.listen = lambda addr: calls.append(addr) # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "debugpy", fake)
monkeypatch.setenv("DEBUGPY", "yes")
monkeypatch.setenv("DEBUGPY_PORT", "5699")
assert debug_module.maybe_listen() is True
assert calls == [("0.0.0.0", 5699)]
def test_debugpy_missing_is_a_message_not_a_crash(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DEBUGPY", "1")
# a None entry makes `import debugpy` raise ImportError, as it does in a prod venv
monkeypatch.setitem(sys.modules, "debugpy", None) # type: ignore[arg-type]
assert debug_module.maybe_listen() is False
+486
View File
@@ -0,0 +1,486 @@
"""NUT: the wire protocol, the health rules, and the gates around the only write path.
The protocol tests run against a fake upsd over a real socket — this client speaks a
line protocol with quoting rules, and a mock that agrees with my memory of it proves
nothing. The health rules and the command gates are pure functions and are asserted
directly, because those are what decide whether a battery test gets sent.
"""
from __future__ import annotations
import asyncio
import pytest
import pytest_asyncio
from dashboards.sources import nut
from dashboards.sources.nut import NutClient, NutError, UpsReading, normalise_celsius
from dashboards.sources.nut_sim import NutSimulator
VAR_ROWS = {
"battery.charge": "100",
"battery.runtime": "3720",
"battery.voltage": "27.3",
"battery.temperature": "300.4", # kelvin, as several drivers report it
"battery.date": "2021/04/01",
"battery.type": "Pb",
"input.voltage": "121.8",
"input.voltage.nominal": "120",
"input.transfer.low": "92",
"input.transfer.high": "139",
"output.voltage": "120.9",
"ups.load": "34",
"ups.realpower.nominal": "900",
"ups.load.high": "1500",
"ups.status": "OL CHRG",
"ups.mfr": "Cyber Power Systems",
"ups.model": "PR1500LCDRT2U",
"battery.test.result": "PASSED",
"battery.test.date": "2026-09-01",
}
CMDS = ["beeper.mute", "test.panel.start", "test.battery.start.quick",
"test.battery.start.deep", "load.off"]
class FakeUpsd:
"""Just enough of upsd's protocol to exercise the client, over a real socket."""
def __init__(self, *, require_auth: bool = False, close_early: bool = False,
hang: bool = False) -> None:
self.require_auth = require_auth
self.close_early = close_early
self.hang = hang
self.received: list[str] = []
self.authenticated = False
self._server: asyncio.AbstractServer | None = None
async def start(self) -> int:
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
await self._server.start_serving()
return int(self._server.sockets[0].getsockname()[1])
async def stop(self) -> None:
if self._server:
self._server.close()
await self._server.wait_closed()
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
if self.close_early:
writer.close()
return
while True:
raw = await reader.readline()
if not raw:
break
line = raw.decode().strip()
self.received.append(line)
verb, _, rest = line.partition(" ")
await self._respond(writer, verb, rest)
async def _respond(self, writer: asyncio.StreamWriter, verb: str, rest: str) -> None:
def send(text: str) -> None:
writer.write((text + "\n").encode())
if verb == "LIST":
kind, _, target = rest.partition(" ")
if kind == "UPS":
send("BEGIN LIST UPS")
send('UPS "cyberpower" "Cyber Power Systems PR1500LCDRT2U"')
send("END LIST UPS")
elif kind == "VAR":
send("BEGIN LIST VAR")
for key, value in VAR_ROWS.items():
send(f'VAR "{_strip(target)}" "{key}" "{value}"')
send("END LIST VAR")
elif kind == "CMD":
send("BEGIN LIST CMD")
for command in CMDS:
send(f'CMD "{_strip(target)}" "{command}"')
send("END LIST CMD")
elif kind == "RW":
send("BEGIN LIST RW")
send(f'RW "{_strip(target)}" "battery.energysave" "0"')
send("END LIST RW")
else:
send("ERR UNKNOWN-COMMAND")
elif verb == "USER":
self.user = rest
send("OK")
elif verb == "PASS":
self.authenticated = not self.require_auth or getattr(self, "user", "") == "monitor"
send("OK" if self.authenticated else "ERR ACCESS-DENIED")
elif verb == "INSTCMD":
if self.require_auth and not self.authenticated:
send("ERR ACCESS-DENIED")
elif self.hang:
await asyncio.sleep(30)
else:
send("OK Send shorthand command successful")
elif verb == "LOGOUT":
send("OK Goodbye")
else:
send("ERR UNKNOWN-COMMAND")
def _strip(value: str) -> str:
return value.strip().strip('"')
@pytest_asyncio.fixture
async def upsd():
server = FakeUpsd()
port = await server.start()
yield server, port
await server.stop()
async def test_list_ups_parses_names_and_descriptions(upsd) -> None:
_server, port = upsd
async with NutClient("127.0.0.1", port) as client:
ups_list = await client.list_ups()
assert ups_list[0]["name"] == "cyberpower"
assert ups_list[0]["description"] == "Cyber Power Systems PR1500LCDRT2U"
async def test_list_vars_keeps_spaces_and_quotes(upsd) -> None:
_server, port = upsd
async with NutClient("127.0.0.1", port) as client:
variables = await client.list_vars("cyberpower")
assert variables["ups.mfr"] == "Cyber Power Systems"
assert variables["battery.charge"] == "100"
assert variables["ups.status"] == "OL CHRG"
async def test_list_cmds_returns_what_the_driver_offers(upsd) -> None:
_server, port = upsd
async with NutClient("127.0.0.1", port) as client:
commands = await client.list_cmds("cyberpower")
assert commands == CMDS
async def test_list_rw_vars_names_the_variables_not_the_ups(upsd) -> None:
"""`RW <ups> <variable>`: reading field 1 returns the UPS name as a writable variable."""
_server, port = upsd
async with NutClient("127.0.0.1", port) as client:
rw = await client.list_rw_vars("cyberpower")
assert rw == ["battery.energysave"]
async def test_instcmd_without_credentials_refuses_locally(upsd) -> None:
"""No socket write: a dashboard must not even try when it knows it cannot authorise."""
_server, port = upsd
async with NutClient("127.0.0.1", port) as client:
with pytest.raises(NutError) as excinfo:
await client.instcmd("cyberpower", "test.panel.start")
assert excinfo.value.code == "ACCESS-DENIED"
assert "INSTCMD" not in _server.received or not any("INSTCMD" in line for line in _server.received)
async def test_instcmd_with_credentials_succeeds() -> None:
server = FakeUpsd(require_auth=True)
port = await server.start()
try:
async with NutClient("127.0.0.1", port, username="monitor", password="pw") as client:
assert await client.login() is True
reply = await client.instcmd("cyberpower", "test.panel.start")
assert "OK" in reply
assert any(line.startswith("INSTCMD") for line in server.received)
finally:
await server.stop()
async def test_upsd_rejection_carries_the_error_code() -> None:
server = FakeUpsd(require_auth=True)
port = await server.start()
try:
async with NutClient("127.0.0.1", port, username="nobody", password="pw") as client:
with pytest.raises(NutError) as excinfo:
await client.login()
assert excinfo.value.code == "ACCESS-DENIED"
assert "upsd.users" in str(excinfo.value) or "refused" in str(excinfo.value) or str(excinfo.value)
finally:
await server.stop()
async def test_connection_refused_is_reported_as_unreachable() -> None:
"""A closed port must read as 'upsd is not answering', never as an empty UPS list."""
client = NutClient("127.0.0.1", 1, timeout=1.0)
with pytest.raises(NutError) as excinfo:
await client.connect()
assert excinfo.value.code == "UNREACHABLE"
async def test_server_closing_early_is_reported() -> None:
server = FakeUpsd(close_early=True)
port = await server.start()
try:
async with NutClient("127.0.0.1", port, timeout=2.0) as client:
with pytest.raises(NutError) as excinfo:
await client.list_ups()
assert excinfo.value.code == "CLOSED"
finally:
await server.stop()
async def test_a_silent_upsd_times_out_instead_of_hanging() -> None:
server = FakeUpsd(hang=True)
port = await server.start()
try:
async with NutClient("127.0.0.1", port, timeout=0.3) as client:
with pytest.raises(NutError) as excinfo:
await client._send("INSTCMD cyberpower beeper.mute")
await client._read_line()
assert excinfo.value.code == "TIMEOUT"
finally:
await server.stop()
# ---- reading semantics ---------------------------------------------------- #
def reading(**overrides: str) -> UpsReading:
values = dict(VAR_ROWS)
values.update(overrides)
return UpsReading(name="cyberpower", description="Cyber Power PR1500LCDRT2U",
vars=values, commands=list(CMDS))
def test_status_flags_and_state_chips() -> None:
ups = reading()
assert ups.status_flags == ["OL", "CHRG"]
labels = {state["label"] for state in ups.states()}
assert "online" in labels and "charging" in labels
assert all(state["severity"] in ("ok", "warn", "fail", "info") for state in ups.states())
def test_unknown_status_flag_is_shown_not_dropped() -> None:
ups = reading(**{"ups.status": "OL OFF"})
ids = {state["id"] for state in ups.states()}
assert "OFF" in ids, "an unrecognised-in-combination flag must still be shown, not swallowed"
def test_on_battery_is_derived_from_the_flag_not_from_charge() -> None:
assert reading().on_battery is False
assert reading(**{"ups.status": "OB"}).on_battery is True
def test_alarms_are_split_and_cleaned() -> None:
ups = reading(**{"ups.alarm": "BATT.VERIFYFAIL, OVERLOAD "})
assert ups.alarms == ["BATT.VERIFYFAIL", "OVERLOAD"]
def test_number_returns_none_for_absent_and_non_numeric() -> None:
ups = reading()
assert ups.number("battery.charge") == 100.0
assert ups.number("does.not.exist") is None
assert ups.number("ups.status") is None
@pytest.mark.parametrize("raw,expected", [
(None, None), (27.4, 27.4), (300.4, 27.25), (150.0, 150.0), (151.0, -122.15),
])
def test_kelvin_is_detected_because_the_driver_cannot_be_trusted(raw, expected) -> None:
result = normalise_celsius(raw)
assert result == (None if expected is None else pytest.approx(expected, abs=0.01))
# ---- health assessment ---------------------------------------------------- #
def test_a_healthy_ups_reports_every_check_as_ok() -> None:
health = nut.assess(reading(**{"battery.temperature": "27.4", "battery.mfr.date": "2026-01-01",
"ups.status": "OL"}), now=1_767_225_600.0)
assert health["overall"] == "healthy"
assert health["counts"]["fail"] == 0 and health["counts"]["warn"] == 0
assert any(check["name"] == "Power path" for check in health["checks"])
def test_on_battery_with_little_runtime_is_a_failure() -> None:
health = nut.assess(reading(**{"ups.status": "OB", "battery.charge": "18",
"battery.runtime": "90",
"battery.temperature": "27.4"}), now=1_767_225_600.0)
assert health["overall"] == "at risk"
runtime = next(c for c in health["checks"] if c["name"] == "Runtime estimate")
assert runtime["status"] in ("warn", "fail")
def test_old_battery_is_warned_about_with_the_actual_age() -> None:
health = nut.assess(reading(**{"battery.temperature": "27.4", "battery.mfr.date": "2019-01-01"}),
now=1_767_225_600.0)
age = next(c for c in health["checks"] if c["name"] == "Battery age")
assert age["status"] == "warn"
assert "2019" in age["detail"] or "7" in age["detail"]
def test_a_kelvin_temperature_is_not_reported_as_a_melting_battery() -> None:
health = nut.assess(reading(), now=1_767_225_600.0) # 300.4 in the fixture
temp = next(c for c in health["checks"] if c["name"] == "Battery temperature")
assert temp["status"] == "ok"
assert "kelvin" in temp["detail"]
assert 20 < temp["value"] < 35
def test_a_hot_battery_fails() -> None:
health = nut.assess(reading(**{"battery.temperature": "52"}), now=1_767_225_600.0)
temp = next(c for c in health["checks"] if c["name"] == "Battery temperature")
assert temp["status"] == "fail"
def test_a_failed_self_test_is_surfaced() -> None:
health = nut.assess(reading(**{"battery.test.result": "FAILED", "battery.temperature": "27.4"}),
now=1_767_225_600.0)
check = next(c for c in health["checks"] if c["name"] == "Last self-test")
assert check["status"] in ("warn", "fail")
def test_missing_test_history_is_unknown_not_ok() -> None:
values = {key: value for key, value in VAR_ROWS.items()
if not key.startswith("battery.test")}
values["battery.temperature"] = "27.4"
health = nut.assess(nut.UpsReading(name="cyberpower", vars=values, commands=[]),
now=1_767_225_600.0)
check = next(c for c in health["checks"] if c["name"] == "Last self-test")
assert check["status"] == "unknown"
def test_alarms_drive_the_overall_verdict() -> None:
health = nut.assess(reading(**{"ups.alarm": "BATT.FAIL", "battery.temperature": "27.4"}),
now=1_767_225_600.0)
assert health["overall"] in ("degraded", "at risk")
# ---- command gating ------------------------------------------------------- #
def base_commands() -> list[str]:
return list(CMDS)
def by_name(items: list[dict], command: str) -> dict:
return next(item for item in items if item["command"] == command)
def test_commands_are_disabled_when_actions_are_off() -> None:
items = nut.available_commands(base_commands(), allow_actions=False, read_only=False, has_credentials=True,
on_battery=False)
deep = by_name(items, "test.battery.start.deep")
assert deep["enabled"] is False
assert "NUT_ALLOW_ACTIONS" in deep["reason"]
def test_commands_are_disabled_without_credentials_that_have_actions() -> None:
items = nut.available_commands(base_commands(), allow_actions=True, read_only=False, has_credentials=False,
on_battery=False)
assert by_name(items, "beeper.mute")["enabled"] is False
assert "upsd.users" in by_name(items, "beeper.mute")["reason"]
def test_dangerous_commands_are_blocked_while_on_battery() -> None:
"""A deep test while already on battery can end the runtime early — that is the whole
thing the UPS is protecting against, so the UI must not offer it."""
items = nut.available_commands(base_commands(), allow_actions=True, read_only=False, has_credentials=True,
on_battery=True)
deep = by_name(items, "test.battery.start.deep")
assert deep["enabled"] is False
assert "battery" in deep["reason"].lower()
safe = by_name(items, "beeper.mute")
assert safe["enabled"] is True
def test_read_only_overrides_everything_else() -> None:
items = nut.available_commands(base_commands(), allow_actions=True, read_only=True,
has_credentials=True, on_battery=False)
assert items and all(item["enabled"] is False for item in items)
assert any("DASH_READ_ONLY" in (item["reason"] or "") for item in items)
def test_risk_levels_are_labelled_per_command() -> None:
items = nut.available_commands(base_commands(), allow_actions=True, read_only=False, has_credentials=True,
on_battery=False)
assert by_name(items, "beeper.mute")["risk"] == nut.RISK_SAFE
assert by_name(items, "test.battery.start.deep")["risk"] == nut.RISK_DANGEROUS
assert by_name(items, "load.off")["risk"] == nut.RISK_DANGEROUS
def test_commands_the_driver_does_not_advertise_are_never_offered() -> None:
items = nut.available_commands(["beeper.mute"], allow_actions=True, read_only=False, has_credentials=True,
on_battery=False)
assert [item["command"] for item in items] == ["beeper.mute"]
def test_commands_outside_the_risk_catalog_are_never_offered() -> None:
"""A driver can advertise anything; the dashboard only sends what it has a risk label for."""
items = nut.available_commands(["beeper.mute", "something.new"], allow_actions=True,
read_only=False, has_credentials=True, on_battery=False)
assert [item["command"] for item in items] == ["beeper.mute"]
def test_command_advice_catalog_is_complete_for_the_ui() -> None:
advice = nut.command_advice("test.battery.start.deep")
assert advice and advice["risk"] == nut.RISK_DANGEROUS
assert advice["label"] and advice["help"]
assert nut.command_advice("not.a.command") is None
# ---- payload & simulator -------------------------------------------------- #
def test_payload_carries_numbers_health_and_what_the_driver_offers() -> None:
payload = nut.payload(reading(), now=1_767_225_600.0)
assert payload["name"] == "cyberpower"
assert payload["identity"]["model"] == "PR1500LCDRT2U"
assert payload["numbers"]["charge_pct"] == 100.0
assert payload["numbers"]["battery_temperature"] == pytest.approx(27.25, abs=0.01)
assert payload["health"]["overall"] in ("healthy", "degraded", "at risk")
assert payload["advertised_commands"] == CMDS
assert payload["on_battery"] is False
# the raw dump keeps the original kelvin string: normalising for charts must not
# destroy the value the operator would compare against `upsc`
assert payload["extra"] or payload["status"] == "OL CHRG"
def test_history_samples_only_include_real_numbers() -> None:
values = {key: value for key, value in VAR_ROWS.items() if key != "battery.runtime"}
samples = nut.history_samples(nut.UpsReading(name="cyberpower", vars=values, commands=[]))
assert "ups.charge_pct" in samples
assert "ups.battery_runtime_seconds" not in samples
assert all(isinstance(value, float) and value == value for value in samples.values())
def test_history_samples_normalise_the_temperature() -> None:
samples = nut.history_samples(reading())
assert samples["ups.battery_temperature_c"] == pytest.approx(27.25, abs=0.01)
async def test_simulator_reads_like_upsd_and_is_labelled() -> None:
readings = await NutSimulator().read()
assert readings, "the simulator must present at least one UPS"
for ups in readings:
assert "(simulated)" in ups.description, "simulated data must be identifiable"
assert ups.vars["battery.charge"]
assert "test.battery.start.deep" in ups.commands
async def test_simulator_commands_change_the_model_like_a_real_driver() -> None:
sim = NutSimulator()
name = (await sim.read())[0].name
assert "OK" in sim.command(name, "test.battery.start.quick")
after = (await sim.read())[0]
assert after.on_battery is True, "a quick test puts the load on battery"
assert "OK" in sim.command(name, "test.battery.stop")
assert (await sim.read())[0].on_battery is False
async def test_simulator_discharges_while_on_battery() -> None:
"""A simulated UPS that never drains cannot exercise a runtime or health path."""
sim = NutSimulator()
name = (await sim.read())[0].name
before = float((await sim.read())[0].vars["battery.charge"])
sim.command(name, "test.battery.start.deep")
ups = next(item for item in sim.ups if item.name == name)
ups.on_battery = True
for _ in range(30):
ups.step(60.0) # half an hour of model time on battery
assert ups.charge < before
async def test_simulator_command_for_unknown_ups_raises() -> None:
sim = NutSimulator()
with pytest.raises(KeyError):
sim.command("nope", "beeper.mute")
+217
View File
@@ -0,0 +1,217 @@
"""The poll loop: isolation, last-good-value, and events that mark transitions only.
These three behaviours are why the dashboard can be trusted when something is broken, so
they are asserted directly rather than through HTTP:
* a source that throws must not take the loop, the hub, or its neighbours down;
* a failed poll keeps the previous payload and is marked stale — never zeroed;
* ok→fail and fail→ok each write exactly one event; 500 more failures write nothing;
* maintenance failing is logged, because a silent rollup failure means every long-window
chart is quietly empty (that bug is what the logging was added for).
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Iterator
from typing import Any
import pytest
from dashboards.poller import Hub, Job, Result
from dashboards.rates import RateEstimator
from dashboards.store import Event, Sample, SampleStore
@pytest.fixture
def store(tmp_path: Any) -> Iterator[SampleStore]:
instance = SampleStore(tmp_path / "poller.sqlite3")
try:
yield instance
finally:
instance.close()
def ok_result(value: float = 1.0) -> Result:
return Result(payload={"value": value},
samples=[Sample("host", "gw", "cpu.usage_pct", value, time.time())])
async def failing() -> Result:
raise ConnectionError("agent is down")
async def test_a_successful_poll_stores_payload_samples_and_health(store) -> None:
job = Job("host:gw", "system", "gw", 0.5, lambda: asyncio.sleep(0, ok_result(7.0)))
outcome = await job.run_once(store)
assert outcome is True, job.state.error
assert job.state.ok is True and job.state.error is None
assert job.state.payload == {"value": 7.0}
assert job.state.successes == 1 and job.state.samples_written == 1
assert job.state.latency_ms is not None
assert store.recent_events(limit=5) == [], "a first successful poll is not a recovery"
async def test_a_failing_poll_keeps_the_last_good_payload(store) -> None:
"""The UI shows `stale 12s` against real numbers; it must not show zeros or n/a."""
job = Job("host:gw", "system", "gw", 0.5, lambda: asyncio.sleep(0, ok_result(7.0)))
await job.run_once(store)
job.fn = failing
assert await job.run_once(store) is False
assert job.state.ok is False
assert job.state.payload == {"value": 7.0}, "a dead host must not blank its last values"
assert job.state.error == "ConnectionError: agent is down"
assert job.state.as_json()["age_seconds"] is not None
async def test_transitions_write_one_event_each_and_spam_writes_none(store) -> None:
job = Job("host:gw", "system", "gw", 0.5, lambda: asyncio.sleep(0, ok_result()),
grace_failures=1)
await job.run_once(store)
job.fn = failing
for _ in range(5):
await job.run_once(store)
events = store.recent_events(limit=50)
down = [event for event in events if event.kind == "unreachable"]
assert len(down) == 1, "five consecutive failures must not log five events"
assert down[0].host == "gw", "events carry the display label, not the internal job key"
assert down[0].severity == "error"
job.fn = lambda: asyncio.sleep(0, ok_result())
await job.run_once(store)
events = store.recent_events(limit=50)
up = [event for event in events if event.kind == "reachable"]
assert len(up) == 1
assert up[0].message == "gw is answering again after 5 failures"
async def test_one_failure_reads_as_a_failure_not_failures(store) -> None:
job = Job("host:gw", "system", "gw", 0.5, lambda: asyncio.sleep(0, ok_result()))
await job.run_once(store)
job.fn = failing
await job.run_once(store)
job.fn = lambda: asyncio.sleep(0, ok_result())
await job.run_once(store)
up = [event for event in store.recent_events(limit=50) if event.kind == "reachable"]
assert up[0].message.endswith("after 1 failure")
async def test_grace_allows_a_blip_without_an_event(store) -> None:
"""One failure inside the grace window must not page anybody."""
job = Job("host:gw", "system", "gw", 0.5, failing, grace_failures=2)
await job.run_once(store)
assert store.recent_events(limit=10) == []
await job.run_once(store)
events = store.recent_events(limit=10)
assert [event.kind for event in events] == ["unreachable"]
assert events[0].severity == "warn", "never answered is a config problem, not an outage"
assert "never answered" in events[0].message
for _ in range(4):
await job.run_once(store)
assert len(store.recent_events(limit=10)) == 1, "one event per outage, not per poll"
async def test_a_slow_poll_never_overlaps_itself(store) -> None:
calls = 0
gate = asyncio.Event()
async def slow() -> Result:
nonlocal calls
calls += 1
await gate.wait()
return ok_result()
job = Job("host:gw", "system", "gw", 0.5, slow)
first = asyncio.create_task(job.run_once(store))
await asyncio.sleep(0.01)
assert await job.run_once(store) is False, "an overlapping poll double-counts rates"
gate.set()
assert await first is True
assert calls == 1
async def test_a_counter_that_resets_is_reported_not_silently_negative() -> None:
"""The reset path is what ships: an agent restart must not read as negative throughput."""
rates = RateEstimator(span=10.0)
first, _ = rates.rate("cpu.total", 1000.0, now=100.0)
assert first is None, "one sample cannot produce a rate"
second, window = rates.rate("cpu.total", 2000.0, now=110.0)
assert second == pytest.approx(100.0) and window == pytest.approx(10.0)
after, _ = rates.rate("cpu.total", 5.0, now=120.0) # agent restarted
assert after is None, "a reset counter must read n/a, never as negative throughput"
async def test_the_hub_runs_every_job_and_stops_cleanly(store) -> None:
counts = {"a": 0, "b": 0}
async def good(key: str) -> Result:
counts[key] += 1
return ok_result()
hub = Hub(store)
hub.add(Job("host:a", "system", "a", 0.5, lambda: good("a")))
hub.add(Job("host:b", "system", "b", 0.5, failing))
hub.add(Job("llm:off", "llm", "off", 0.5, failing, enabled=False))
hub.start()
try:
await asyncio.sleep(1.2)
finally:
await hub.stop()
assert counts["a"] >= 2, "a neighbour that throws must not slow the healthy one"
states = {state["key"]: state for state in hub.states()}
assert states["host:a"]["ok"] is True
assert states["host:b"]["ok"] is False
assert states["llm:off"]["configured"] is False, "not configured is not the same as broken"
assert hub.jobs["host:b"].state.failures >= 1
async def test_states_can_be_filtered_to_one_dashboard(store) -> None:
hub = Hub(store)
hub.add(Job("host:a", "system", "a", 0.5, lambda: asyncio.sleep(0, ok_result())))
hub.add(Job("nut", "ups", "NUT server", 0.5, lambda: asyncio.sleep(0, ok_result())))
assert [s["key"] for s in hub.states("ups")] == ["nut"]
async def test_maintenance_failure_is_logged_not_swallowed(store, monkeypatch,
caplog: pytest.LogCaptureFixture) -> None:
"""A broken rollup means every chart past the raw window is empty. Retrying quietly
turned a data-loss bug into nothing at all — this is the regression that added the log."""
def boom() -> dict[str, int]:
raise RuntimeError("no such table: sample_rollup")
monkeypatch.setattr(store, "maintain", boom)
hub = Hub(store)
task = asyncio.create_task(hub._maintain_loop(interval=0.02))
try:
with caplog.at_level(logging.ERROR, logger="dashboards.poller"):
await asyncio.sleep(0.15)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert "store maintenance failed" in caplog.text
assert "no such table" in caplog.text, "the log line must carry the real reason"
async def test_maintenance_runs_from_the_hub_and_records_its_pass(store) -> None:
hub = Hub(store)
calls = []
store.maintain = lambda: calls.append(1) or {"to_minute": 0, "to_hour": 0, # type: ignore[method-assign]
"pruned": 0}
task = asyncio.create_task(hub._maintain_loop(interval=0.02))
try:
await asyncio.sleep(0.1)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert calls, "the maintenance loop must actually call maintain"
async def test_events_written_by_a_job_land_in_the_store(store) -> None:
event = Event(1_700_000_000.0, "ups", "cyberpower", "status", "warn", "OL → OB")
job = Job("nut", "ups", "NUT server", 0.5,
lambda: asyncio.sleep(0, Result(payload={}, events=[event])))
await job.run_once(store)
assert [e.message for e in store.recent_events(limit=5)] == ["OL → OB"]
+112
View File
@@ -0,0 +1,112 @@
"""The hand-rolled Prometheus parser, tested against real engine expositions.
This parser exists so the app has no Prometheus dependency for the two upstreams that
already speak the format. That makes it load-bearing on text we do not control: llama.cpp
emits names with colons, vLLM emits label sets and histograms, and both are free to change
them. Fixtures are captured verbatim (scripts/capture-fixtures.sh --with-llm).
"""
from __future__ import annotations
import math
from dashboards.prom import Prom
def test_parses_counters_and_gauges_from_a_real_capture(metrics_text) -> None:
prom = Prom(metrics_text("gufo"))
assert prom.scalar(["llamacpp:tokens_predicted_total"]) == 877_336.0
assert prom.scalar(["llamacpp:kv_cache_usage_ratio"]) == 0.0
assert prom.parse_errors == 0
def test_help_and_type_comments_are_not_samples(metrics_text) -> None:
prom = Prom(metrics_text("gufo"))
assert not any(name.startswith("#") for name in prom.series)
assert "# HELP llamacpp:prompt_tokens_total" not in prom.series
def test_labels_are_parsed_and_filterable(metrics_text) -> None:
prom = Prom(metrics_text("vllm"))
running = prom.values(["vllm:num_requests_running"])
assert running, "vLLM exposes num_requests_running"
by_model = prom.by_label(["vllm:generation_tokens_total"], "model_name")
assert by_model and all(model for model in by_model)
def test_aggregations(metrics_text) -> None:
prom = Prom(metrics_text("vllm"))
total = prom.scalar(["vllm:prompt_tokens_total"], agg="sum")
assert total is not None and total > 1e6
# ratios must never be summed across label sets — a 3-way sum would read as 300% used
usage = prom.scalar(["vllm:kv_cache_usage_perc"], agg="max")
assert usage is not None and 0.0 <= usage <= 1.0
def test_missing_series_is_none_not_zero(metrics_text) -> None:
prom = Prom(metrics_text("gufo"))
assert prom.scalar(["vllm:does_not_exist"]) is None
assert prom.values(["vllm:does_not_exist"]) == []
assert prom.by_label(["nope"], "model_name") == {}
def test_histogram_mean_and_p95(metrics_text) -> None:
hist = Prom(metrics_text("vllm")).histogram(["vllm:time_to_first_token_seconds"])
assert hist is not None
assert hist["count"] and hist["count"] > 0
assert hist["mean"] and hist["mean"] > 0
assert hist["p95"] is not None
assert hist["p95"] >= hist["mean"], "p95 below the mean means the interpolation is wrong"
assert math.isfinite(hist["p95"])
def test_histogram_resolves_through_suffixes(metrics_text) -> None:
prom = Prom(metrics_text("vllm"))
# the bare name never appears in the exposition; only _bucket/_sum/_count do
assert "vllm:time_to_first_token_seconds" not in prom.series
assert prom.resolve_hist(["vllm:time_to_first_token_seconds"]) == "vllm:time_to_first_token_seconds"
assert prom.resolve(["vllm:time_to_first_token_seconds"]) is None
def test_resolve_returns_the_first_present_name() -> None:
prom = Prom('a_total 5\nb_total 7\n')
assert prom.resolve(["missing_total", "b_total", "a_total"]) == "b_total"
assert prom.resolve(["nope"]) is None
def test_label_match_narrows_the_series() -> None:
prom = Prom('x{engine="0"} 1\nx{engine="1"} 2\n')
assert prom.scalar(["x"], agg="sum") == 3
assert prom.scalar(["x"], label_match={"engine": "1"}) == 2
assert prom.scalar(["x"], label_match={"engine": "9"}) is None
def test_malformed_lines_are_counted_not_fatal() -> None:
prom = Prom("good_metric 1\nthis is not prometheus\nalso_bad{unclosed 2\n")
assert prom.scalar(["good_metric"]) == 1.0
assert prom.parse_errors >= 1
def test_empty_exposition_is_harmless() -> None:
prom = Prom("")
assert prom.series == {}
assert prom.scalar(["anything"]) is None
assert prom.histogram(["anything"]) is None
def test_quantile_ignores_non_finite_values() -> None:
prom = Prom('m_sum 10\ncount 2\nm{le="1"} NaN\n')
assert prom.scalar(["m"], label_match={"le": "1"}) is None
def test_multiple_histogram_label_sets_do_not_invent_a_p95(metrics_text) -> None:
"""Mixing per-DP-rank buckets into one cumulative curve would fabricate a percentile."""
two = (
'h_sum{dp="0"} 10\nh_sum{dp="1"} 10\n'
'h_count{dp="0"} 2\nh_count{dp="1"} 2\n'
'h_bucket{dp="0",le="1"} 1\nh_bucket{dp="0",le="+Inf"} 2\n'
'h_bucket{dp="1",le="1"} 1\nh_bucket{dp="1",le="+Inf"} 2\n'
)
hist = Prom(two).histogram(["h"])
assert hist is not None
assert hist["mean"] == 5.0
assert hist["p95"] is None, "p95 across mixed label sets is a made-up number"
+95
View File
@@ -0,0 +1,95 @@
"""Rate estimation over a labelled window.
Every throughput number in this dashboard is Δcounter/Δt. If the estimator returns a
plausible-looking number for a counter that restarted, or a silent 0 for a scrape that
arrived late, the UI cannot tell the difference — so the None cases matter more here than
the happy path.
"""
from __future__ import annotations
import math
import pytest
from dashboards.rates import RateEstimator
def test_first_sample_has_no_rate() -> None:
r = RateEstimator(span=10)
assert r.rate("cpu", 100.0, now=1_000.0) == (None, None)
def test_second_sample_gives_rate_and_window() -> None:
r = RateEstimator(span=10)
r.rate("cpu", 100.0, now=1_000.0)
rate, window = r.rate("cpu", 300.0, now=1_010.0)
assert rate == 20.0
assert window == 10.0
def test_window_is_labelled_with_the_span_actually_used() -> None:
r = RateEstimator(span=10)
r.rate("x", 0.0, now=0.0)
r.rate("x", 10.0, now=2.0) # inside the span
rate, window = r.rate("x", 30.0, now=11.0)
# base is the sample 11s ago (outside the span) -> falls back to the previous sample
assert window == 9.0
assert rate == pytest.approx(20.0 / 9.0)
def test_none_value_does_not_poison_the_window() -> None:
r = RateEstimator(span=10)
r.rate("x", 10.0, now=0.0)
assert r.rate("x", None, now=1.0) == (None, None)
rate, _window = r.rate("x", 30.0, now=2.0)
assert rate == 10.0, "the None scrape must not become the baseline"
def test_nan_and_inf_are_treated_as_missing() -> None:
r = RateEstimator(span=10)
r.rate("x", 1.0, now=0.0)
for bad in (float("nan"), float("inf")):
assert r.rate("x", bad, now=1.0) == (None, None)
def test_counter_reset_yields_none_once_then_recovers() -> None:
"""A restart must not report a huge negative — or, worse, a huge positive — rate."""
r = RateEstimator(span=60)
r.rate("tokens", 5_000.0, now=0.0)
rate, _ = r.rate("tokens", 10.0, now=10.0) # process restarted
assert rate is None
rate, _ = r.rate("tokens", 60.0, now=20.0)
assert rate == 5.0, "the window re-seeds from the post-restart counter"
def test_flat_counter_reports_zero_not_none() -> None:
r = RateEstimator(span=10)
r.rate("idle", 5.0, now=0.0)
rate, window = r.rate("idle", 5.0, now=5.0)
assert rate == 0.0 and window == 5.0
def test_keys_are_independent() -> None:
r = RateEstimator(span=10)
r.rate("a", 0.0, now=0.0)
r.rate("b", 100.0, now=0.0)
assert r.rate("a", 10.0, now=1.0)[0] == 10.0
assert r.rate("b", 100.0, now=1.0)[0] == 0.0
def test_forget_drops_a_removed_target() -> None:
r = RateEstimator(span=10)
for key in ("driveripper:cpu.total", "driveripper:disk.read", "deskwork:cpu.total"):
r.rate(key, 1.0, now=0.0)
assert r.forget("driveripper:") == 2
assert r.keys() == ["deskwork:cpu.total"]
# and the surviving target is untouched
assert r.rate("deskwork:cpu.total", 11.0, now=1.0)[0] == 10.0
def test_history_is_trimmed_to_max_samples() -> None:
r = RateEstimator(span=1e6, max_samples=8)
for i in range(50):
r.rate("x", float(i), now=float(i))
assert len(r._samples["x"]) <= 8 # bounded memory is the contract
assert all(math.isfinite(v) for _, v in r._samples["x"])
+254
View File
@@ -0,0 +1,254 @@
"""History store: rollups, retention, and the honesty of an empty chart.
The rollup queries are plain SQL with positional bindings, and a mismatch there used to
raise on every maintenance tick while the poller swallowed it — so every window past the
raw retention was silently empty. These tests fail loudly if that ever happens again.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from dashboards.store import (
HOUR,
MINUTE,
RES_HOUR,
RES_MIN,
RES_RAW,
Event,
Sample,
SampleStore,
samples_from_flat,
select_window,
)
def add_series(db, source: str, host: str, metric: str, values, *, start: float, step: float,
res: str = RES_RAW) -> None:
for i, value in enumerate(values):
# the private handle is the point: writing at an explicit resolution is what
# the resolution-choice tests need
db._db.execute(
"INSERT INTO sample (source, host, metric, res, ts, value) VALUES (?,?,?,?,?,?)",
(source, host, metric, res, start + i * step, float(value)),
)
db._db.commit()
def test_store_starts_in_wal_mode(db) -> None:
"""Readers poll while the writer commits; without WAL the API would hit locked errors."""
assert db._db.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
def test_add_and_query_roundtrip(db) -> None:
now = time.time()
db.add("host", "driveripper", "cpu.usage_pct", 42.5, ts=now)
points = db.query("host", "driveripper", "cpu.usage_pct", start=now - 60)
assert points == [(now, 42.5)]
def test_query_is_bounded_and_ascending(db) -> None:
base = 1_700_000_000.0
add_series(db, "host", "h", "m", [3, 1, 2], start=base, step=10)
assert db.query("host", "h", "m", start=base - 1) == [(base, 3.0), (base + 10, 1.0), (base + 20, 2.0)]
assert len(db.query("host", "h", "m", start=base - 1, limit=2)) == 2
def test_query_tail_keeps_the_newest_rows(db) -> None:
base = 1_700_000_000.0
add_series(db, "host", "h", "m", range(100), start=base, step=10)
head = db.query("host", "h", "m", start=base - 1, limit=3)
tail = db.query("host", "h", "m", start=base - 1, limit=3, tail=True)
assert [v for _, v in head] == [0.0, 1.0, 2.0]
assert [v for _, v in tail] == [97.0, 98.0, 99.0]
assert tail == sorted(tail), "tail must still come back oldest-first"
def test_samples_from_flat_drops_unknown_and_none() -> None:
payload: dict[str, Any] = {"a": 1.0, "b": None, "c": "nope"}
samples = samples_from_flat("host", "gw", payload, ts=5.0)
assert [(s.metric, s.value) for s in samples] == [("a", 1.0)]
def test_maintain_rolls_raw_into_minutes(db) -> None:
"""Regression: the rollup statement had 7 placeholders and 6 bindings."""
now = time.time()
# two full, aligned minute buckets — see the hour test for why alignment matters
minute = int(now // MINUTE) * MINUTE
add_series(db, "host", "h", "cpu.usage_pct", [10, 20, 30, 40], start=minute - 4 * MINUTE,
step=1)
add_series(db, "host", "h", "cpu.usage_pct", [1, 1, 1, 1], start=minute - 3 * MINUTE, step=1)
counts = db.maintain(now=now)
assert counts["to_minute"] > 0
minutes = db.query("host", "h", "cpu.usage_pct", res=RES_MIN, start=now - 3600)
assert minutes, "no 1m rollup was written"
assert [round(value) for _ts, value in minutes] == [25, 1]
def test_maintain_is_idempotent(db) -> None:
"""Rollups run on a timer: running twice must not double-count or duplicate rows."""
now = time.time()
minute = int(now // MINUTE) * MINUTE
add_series(db, "host", "h", "m", [10, 20], start=minute - 4 * MINUTE, step=1)
first = db.maintain(now=now)
rows = db.query("host", "h", "m", res=RES_MIN, start=now - 3600)
count = db.count()
second = db.maintain(now=now)
assert rows == db.query("host", "h", "m", res=RES_MIN, start=now - 3600)
assert first["to_minute"] == second["to_minute"]
assert db.count() == count, "re-running maintenance duplicated samples"
def test_maintain_rolls_minutes_into_hours(db) -> None:
now = time.time()
# buckets are aligned to the epoch (`ts/3600*3600`), not to "hours from now", so the
# fixtures start on a boundary or the two hours mix into a third, averaged bucket
hour = int(now // HOUR) * HOUR
# two *complete* hours of minute buckets: the current and previous hour are still
# filling in, and maintenance must not touch them (see the partial-bucket test)
add_series(db, "host", "h", "m", [1] * 60, start=hour - 5 * HOUR, step=60, res=RES_MIN)
add_series(db, "host", "h", "m", [3] * 60, start=hour - 4 * HOUR, step=60, res=RES_MIN)
counts = db.maintain(now=now)
assert counts["to_hour"] > 0
hours = db.query("host", "h", "m", res=RES_HOUR, start=now - 86_400)
assert sorted(round(v) for _, v in hours) == [1.0, 3.0]
def test_maintain_prunes_expired_raw(db) -> None:
now = time.time()
old = now - (db.raw_keep_seconds + 7200)
add_series(db, "host", "h", "m", [1, 2], start=old, step=60)
add_series(db, "host", "h", "fresh", [1], start=now - 60, step=1)
counts = db.maintain(now=now)
assert counts["pruned"] >= 2
assert db.query("host", "h", "m", start=0) == []
def test_maintain_records_timestamp_in_stats(db) -> None:
db.maintain()
stats = db.stats()
assert stats["last_maintain"] is not None
assert "samples_by_res" in stats and "path" in stats
def test_pick_res_prefers_the_finest_resolution_that_fits(db) -> None:
now = time.time()
add_series(db, "host", "h", "m", list(range(500)), start=now - 3600, step=1)
assert db.pick_res("host", "h", "m", 3600, 600) == RES_RAW
add_series(db, "host", "h", "m", list(range(4000)), start=now - 7200, step=1)
assert db.pick_res("host", "h", "m", 7200, 600) == RES_RAW, "raw still fits"
add_series(db, "host", "h", "m", list(range(120)), start=now - 7200, step=60,
res=RES_MIN)
# 4500 raw points blow the 600 budget, so the minute rollup takes over
assert db.pick_res("host", "h", "m", 7200, 600) == RES_MIN
def test_pick_res_never_picks_an_empty_resolution(db) -> None:
"""A fresh install has raw only: choosing 1h because it 'fits' would chart nothing."""
now = time.time()
add_series(db, "host", "h", "m", [1] * 2000, start=now - 7200, step=1)
assert db.pick_res("host", "h", "m", 86_400, 600) == RES_RAW
def test_history_falls_back_to_raw_when_rollups_lag(db) -> None:
now = time.time()
add_series(db, "host", "h", "m", list(range(1500)), start=now - 1500, step=1)
points = db.history("host", "h", "m", span=3600, max_points=600)
assert len(points) > 0, "empty chart while raw data exists"
assert len(points) <= 600
def test_history_always_keeps_the_newest_point(db) -> None:
now = time.time()
values = list(range(2000))
add_series(db, "host", "h", "m", values, start=now - 2000, step=1)
points = db.history("host", "h", "m", span=3600, max_points=50)
assert points[-1][1] == pytest.approx(values[-1])
def test_series_names_and_hosts(db) -> None:
db.add("host", "a", "cpu.usage_pct", 1.0)
db.add("host", "a", "mem.used_pct", 2.0)
db.add("host", "b", "cpu.usage_pct", 3.0)
db.add("llm", "gufo", "llm.decode_tokens_per_second", 4.0)
assert db.series_names("host", "a") == ["cpu.usage_pct", "mem.used_pct"]
assert db.hosts("host") == ["a", "b"]
assert db.series_names("llm") == ["llm.decode_tokens_per_second"]
def test_delete_series_cleans_up(db) -> None:
db.add("host", "a", "gone.metric", 1.0)
db.add("host", "a", "keep.metric", 2.0)
assert db.delete_series("host", "a", "gone.metric") == 1
assert db.series_names("host", "a") == ["keep.metric"]
def test_events_roundtrip_and_filter(db) -> None:
now = time.time()
db.log_event(Event(ts=now, source="host", host="a", kind="down", severity="error",
message="a is not answering", data={"fails": 3}))
db.log_event(Event(ts=now + 1, source="nut", host="cyberpower", kind="state",
severity="warn", message="OL → OB"))
everything = db.recent_events(10)
assert [e.host for e in everything] == ["cyberpower", "a"]
assert everything[-1].data == {"fails": 3}
assert [e.source for e in db.recent_events(10, source="nut")] == ["nut"]
assert len(db.recent_events(1)) == 1
# the API hands out plain JSON, so the boundary has to be serialisable
assert everything[0].as_json()["message"] == "OL → OB"
def test_event_as_json_is_serialisable(db) -> None:
event = Event(ts=1.0, source="host", host="a", kind="up", severity="info", message="ok")
payload = event.as_json()
assert payload["severity"] == "info" and payload["data"] is None
def test_count_filters_by_resolution(db) -> None:
now = time.time()
db.add("host", "h", "m", 1.0, ts=now)
add_series(db, "host", "h", "m", [2.0], start=now, step=1, res=RES_MIN)
assert db.count() == 2
assert db.count(res=RES_RAW) == 1
assert db.count(res=RES_HOUR) == 0
def test_add_many_reports_inserted_rows(db) -> None:
now = time.time()
rows = [Sample("host", "h", f"m{i}", float(i), now) for i in range(5)]
assert db.add_many(rows) == 5
assert db.count() == 5
def test_store_reopens_without_losing_history(tmp_path) -> None:
path = tmp_path / "reopen.sqlite3"
first = SampleStore(path)
now = time.time()
first.add("host", "h", "m", 7.0, ts=now)
first.close()
second = SampleStore(path)
try:
assert second.query("host", "h", "m", start=now - 5) == [(now, 7.0)]
finally:
second.close()
def test_select_window_maps_range_onto_presets() -> None:
assert select_window(60, [300, 3600, 86_400]) == 300
assert select_window(3600, [300, 3600, 86_400]) == 3600
assert select_window(86_401, [300, 3600, 86_400]) == 86_400
def test_rollup_does_not_average_a_partial_minute(db) -> None:
"""A bucket is only rolled once its whole window is in the past, or it gets rewritten."""
now = time.time()
add_series(db, "host", "h", "m", [1, 1, 1], start=now - 5, step=1) # current minute
db.maintain(now=now)
assert db.query("host", "h", "m", res=RES_MIN, start=now - 600) == []
def test_hour_constant_is_used_in_retention(db) -> None:
assert db.raw_keep_seconds == pytest.approx(48 * HOUR)
Generated
+869
View File
@@ -0,0 +1,869 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.16.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" },
{ url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" },
{ url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" },
{ url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" },
{ url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" },
{ url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" },
{ url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" },
{ url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" },
{ url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" },
{ url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" },
{ url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" },
{ url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" },
{ url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" },
{ url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" },
{ url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" },
{ url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" },
{ url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" },
{ url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" },
{ url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" },
{ url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" },
{ url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" },
{ url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" },
{ url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" },
{ url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" },
{ url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" },
{ url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" },
{ url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" },
{ url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" },
{ url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" },
{ url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" },
{ url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" },
{ url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" },
{ url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" },
{ url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" },
{ url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" },
{ url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" },
{ url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" },
{ url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" },
{ url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" },
{ url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" },
{ url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" },
{ url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" },
{ url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" },
{ url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" },
{ url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" },
{ url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" },
{ url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" },
{ url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" },
{ url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" },
{ url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" },
{ url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" },
{ url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" },
]
[[package]]
name = "dashboards"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "debugpy" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.143.3" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic", specifier = ">=2.14.1" },
{ name = "python-dotenv", specifier = ">=1.2.4" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "debugpy", specifier = ">=1.8.22" },
{ name = "pyright", specifier = ">=1.1.414" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.17.1" },
]
[[package]]
name = "debugpy"
version = "1.8.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/9d/3cb6693342acf96802dba89934a5b8da43c201d764ebd1f2c0514a3d42bd/debugpy-1.8.22.tar.gz", hash = "sha256:e489c7268e1c7b41e13b438d9c533d2a7af73fb59bf8cd30fead8286c1c39c4e", size = 1710877, upload-time = "2026-09-15T21:44:02.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/f4/fee6d40f5865aa84e1100c9df1a0f2cf8991776b61f11f433ff277df4415/debugpy-1.8.22-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:c21dd7e1ec22556bb41dc3bf6b86c603e440ec889c4acb27d7206354b2106c54", size = 2493531, upload-time = "2026-09-15T21:44:25.835Z" },
{ url = "https://files.pythonhosted.org/packages/bc/4f/8df91270afbf61fc5ab202951f28451194a7eb093704b26da11cb0ecd308/debugpy-1.8.22-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:c1ffb9953708b648ecf6acd2e5ad2c002bf68785ed618197dd4463a2a7226f39", size = 3994351, upload-time = "2026-09-15T21:44:27.31Z" },
{ url = "https://files.pythonhosted.org/packages/4f/11/2e34a4f21d6155b0c58003fd6fca9819df7d6fe7d7acd83b146b648a57ee/debugpy-1.8.22-cp313-cp313-win32.whl", hash = "sha256:ba810a66b437e3c43ca0ce0892404f010338286263ca8e5108442d76a9485337", size = 5359071, upload-time = "2026-09-15T21:44:29.064Z" },
{ url = "https://files.pythonhosted.org/packages/d0/f5/c2b3e8260388d7b76bc4e5a3a29648a0ca47dca6a2721d6549ff2d944eee/debugpy-1.8.22-cp313-cp313-win_amd64.whl", hash = "sha256:f49b1d6cecf326b63c06d7f517e4a6642777f758c58cb799e7bc5a6dd74721c2", size = 5400362, upload-time = "2026-09-15T21:44:30.857Z" },
{ url = "https://files.pythonhosted.org/packages/ea/95/d97240bb68b86db8d0a07602be068aae9e50f17063d696a4c6f619975311/debugpy-1.8.22-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:e6744ac1850c73c2ba7b29a126cf9ab74efd1831190720e8fee17ef5389c8f5d", size = 2493148, upload-time = "2026-09-15T21:44:32.518Z" },
{ url = "https://files.pythonhosted.org/packages/70/9e/994fe1fb23dd4a5a8d059a0dc95da4b0821907200a61c2d5a4fdc21b6cf8/debugpy-1.8.22-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0c1104233340196e5cbf5514e7dfdbb98968e730cfd7fdd6f3d72a082be838a9", size = 3961025, upload-time = "2026-09-15T21:44:34.387Z" },
{ url = "https://files.pythonhosted.org/packages/37/e0/66319918883cf6dd6dea3805210c3cafd9e65dfaa6f8b852482cff10a026/debugpy-1.8.22-cp314-cp314-win32.whl", hash = "sha256:feea785c7bbeb8cfd5b01a63f9061c899dc468c8be81671141a29305774fa294", size = 5359408, upload-time = "2026-09-15T21:44:36.128Z" },
{ url = "https://files.pythonhosted.org/packages/94/53/5b4d91f7fffb9393ac907133201d1da0c070d57e463ee8e2ec1db3cb63b6/debugpy-1.8.22-cp314-cp314-win_amd64.whl", hash = "sha256:d593a330297332ec435f3965c448b6d500e03f33675cf2063178bc76377a788e", size = 5397429, upload-time = "2026-09-15T21:44:38.429Z" },
{ url = "https://files.pythonhosted.org/packages/24/71/580df4aed96c9b23c9cbb049907d8ac1347050afdb8f450df20649d51578/debugpy-1.8.22-cp315-cp315-macosx_15_0_universal2.whl", hash = "sha256:a9eca6ab09a61534e923064400081e016f7e1d8430cc6e8b7a9cecd2f59e2b07", size = 2493380, upload-time = "2026-09-15T21:44:40.074Z" },
{ url = "https://files.pythonhosted.org/packages/b0/10/a323945b0966e560c40fe48470d2847bfc7692a8f550d7086e9a28f5b623/debugpy-1.8.22-cp315-cp315-manylinux_2_34_x86_64.whl", hash = "sha256:b7dbde1fb822d100802d505b2aed6d0813b1a0a2015d495cb8798b2215d5d1e5", size = 3975456, upload-time = "2026-09-15T21:44:41.623Z" },
{ url = "https://files.pythonhosted.org/packages/ef/f9/4383635281999c3eaa44bf327de523ba42240b35eb87c1130adac4a829c6/debugpy-1.8.22-cp315-cp315-win32.whl", hash = "sha256:225d063f81708c2546999e7edfac0198b2d5c2f144797dc64858f867948633e0", size = 5359469, upload-time = "2026-09-15T21:44:43.234Z" },
{ url = "https://files.pythonhosted.org/packages/7b/14/4fe9f46a9c825c8cbc6dc4aebc2a885b0bceb37ce2a37699c170482e7a40/debugpy-1.8.22-cp315-cp315-win_amd64.whl", hash = "sha256:b17a4896520f1c6da09ce76ec8215df0b85b0b6f3617526a4c65c2315340875a", size = 5397869, upload-time = "2026-09-15T21:44:44.783Z" },
{ url = "https://files.pythonhosted.org/packages/56/3d/c7dc9f35bc9e22cdb53679f844bee40fd5cff871862f60595869a433400d/debugpy-1.8.22-py2.py3-none-any.whl", hash = "sha256:a9e9d3550e15ca479c59333e90845029190531f0cacfedab3b815a57bd913947", size = 5374857, upload-time = "2026-09-15T21:44:54.454Z" },
]
[[package]]
name = "fastapi"
version = "0.143.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/d5/b0aeb0b303650ac87b0411de4aaf3f901b06042dcc3fb00f30b97b7ff585/fastapi-0.143.3.tar.gz", hash = "sha256:b9ab567ee57afb4454004ce04c889f8f0160ac47946b99795887cd6e88484848", size = 469834, upload-time = "2026-10-16T14:14:13.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/1a/03d246befcf307219bb2161c97d8c5d314f84125bf7dd245730a28589828/fastapi-0.143.3-py3-none-any.whl", hash = "sha256:75e8bd68e2f52f77d29b2879749c257d64de2d85282d25270caf2bfccce5efe5", size = 144849, upload-time = "2026-10-16T14:14:11.763Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
{ url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
{ url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
{ url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
{ url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
{ url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
{ url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
{ url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
{ url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
{ url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
{ url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
{ url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
{ url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
{ url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
{ url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
{ url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
{ url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
{ url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
{ url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
{ url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
{ url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
{ url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
{ url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
{ url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
{ url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
{ url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
{ url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
{ url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
{ url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
{ url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
{ url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
{ url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
{ url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
{ url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
{ url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
{ url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
{ url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
{ url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "nodeenv"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pydantic"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyright"
version = "1.1.414"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" },
{ url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" },
{ url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" },
{ url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" },
{ url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" },
{ url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" },
{ url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" },
{ url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" },
{ url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" },
{ url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" },
{ url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" },
]
[[package]]
name = "starlette"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
]
[[package]]
name = "websockets"
version = "17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" },
{ url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" },
{ url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" },
{ url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" },
{ url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" },
{ url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" },
{ url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" },
{ url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" },
{ url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" },
{ url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" },
{ url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" },
{ url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" },
{ url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" },
{ url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" },
{ url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" },
{ url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" },
{ url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" },
{ url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" },
{ url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" },
{ url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" },
{ url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" },
{ url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" },
{ url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" },
{ url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" },
{ url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" },
{ url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" },
{ url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" },
{ url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" },
{ url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" },
{ url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" },
{ url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" },
{ url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
]