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:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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`).
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
Executable
+100
@@ -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"
|
||||
Executable
+46
@@ -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
|
||||
Executable
+65
@@ -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
|
||||
Executable
+107
@@ -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
|
||||
@@ -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__"]
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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());
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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 };
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 & 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -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": []}
|
||||
Vendored
+45
@@ -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
|
||||
Vendored
+662
@@ -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
|
||||
@@ -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)}"
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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 == ""
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user