diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py index dc374ca..96f8d60 100644 --- a/plugins/traefik/routes.py +++ b/plugins/traefik/routes.py @@ -10,7 +10,7 @@ import time from steward.auth.middleware import require_role from steward.models.users import UserRole -from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample +from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample, time_bucket from steward.core.formatting import cert_left, status_class from steward.models.metrics import PluginMetricLatest from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric @@ -430,15 +430,6 @@ async def widget_access_log(): summary=summary, widget_id=widget_id) -def _time_bucket(column, seconds: int): - """The start of the `seconds`-wide window a timestamp falls in, as - host_agent/metrics_query.py buckets. Integer epoch / int rendered as - numeric division (no two scrapes shared a bucket) and overflowed int4 - after 2038 (#5256).""" - return func.date_bin(func.make_interval(0, 0, 0, 0, 0, 0, int(seconds)), column, - func.to_timestamp(0)).label("bucket") - - # A rows or widget request past this logs where its time went, phase by # phase, so a slow query names itself (#5355: 8 s on every poll, warm). SLOW_PAGE_SECONDS = 1.0 @@ -473,7 +464,7 @@ async def router_history(db, names: list[str], since, bucket_secs: int) -> dict[ history: dict[str, list] = {n: [] for n in names} if not history: return history - bucket_col = _time_bucket(TraefikMetric.scraped_at, bucket_secs) + bucket_col = time_bucket(TraefikMetric.scraped_at, bucket_secs) result = await db.execute( select( TraefikMetric.router_name, @@ -509,7 +500,7 @@ async def request_history(db, since, bucket_seconds: int) -> list[dict]: .group_by(TraefikMetric.scraped_at) .subquery() ) - bucket_col = _time_bucket(per_scrape.c.at, bucket_seconds) + bucket_col = time_bucket(per_scrape.c.at, bucket_seconds) rows = (await db.execute( select( func.avg(per_scrape.c.req).label("req_rate"), diff --git a/plugins/unifi/routes.py b/plugins/unifi/routes.py index 55b48ea..2176a3d 100644 --- a/plugins/unifi/routes.py +++ b/plugins/unifi/routes.py @@ -2,11 +2,11 @@ from __future__ import annotations import json from quart import Blueprint, current_app, render_template, request -from sqlalchemy import Integer, cast, func, select +from sqlalchemy import func, select from steward.auth.middleware import require_role from steward.models.users import UserRole -from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds +from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, time_bucket from .models import ( UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiDpiSnapshot, UnifiEvent, UnifiFwRule, UnifiKnownClient, UnifiNetwork, @@ -49,6 +49,58 @@ async def index(): ) +# A UniFi device's `state` code, as (status_dot state, label). Only 1 is +# working; the rest are named rather than all drawn as "Down" (#5207). +DEVICE_STATES = { + 0: ("down", "Offline"), 1: ("up", "Online"), 2: ("pending", "Pending adoption"), + 3: ("degraded", "Firmware mismatch"), 4: ("pending", "Upgrading"), 5: ("pending", "Provisioning"), + 6: ("degraded", "Heartbeat missed"), 7: ("pending", "Adopting"), 8: ("pending", "Deleting"), + 9: ("down", "Inform error"), 10: ("down", "Adoption failed"), 11: ("degraded", "Isolated"), +} +_DEVICE_ORDER = {"down": 0, "degraded": 1, "pending": 2, "up": 3} + + +def device_state(code) -> tuple[str, str]: + return DEVICE_STATES.get(code, ("unknown", f"State {code}")) + + +def devices_in_order(devices) -> list: + """Offline and failing devices first, then ones in a transition, then the + working ones; by name within each.""" + return sorted(devices, key=lambda d: (_DEVICE_ORDER.get(device_state(d.state)[0], 1), + (d.name or d.mac or "").lower())) + + +async def history(db, since, b_secs: int): + """(WAN rows, client-count rows) averaged per `b_secs` window since + `since`, oldest first, for the overview's sparklines.""" + wan_bucket = time_bucket(UnifiWanStat.scraped_at, b_secs) + wan = (await db.execute( + select( + func.avg(UnifiWanStat.latency_ms).label("latency_ms"), + func.avg(UnifiWanStat.rx_bytes_rate).label("rx_bytes_rate"), + func.avg(UnifiWanStat.tx_bytes_rate).label("tx_bytes_rate"), + func.min(UnifiWanStat.scraped_at).label("scraped_at"), + wan_bucket, + ) + .where(UnifiWanStat.scraped_at >= since) + .group_by(wan_bucket) + .order_by(wan_bucket) + )).all() + cs_bucket = time_bucket(UnifiClientSnapshot.scraped_at, b_secs) + clients = (await db.execute( + select( + func.avg(UnifiClientSnapshot.total_clients).label("total_clients"), + func.min(UnifiClientSnapshot.scraped_at).label("scraped_at"), + cs_bucket, + ) + .where(UnifiClientSnapshot.scraped_at >= since) + .group_by(cs_bucket) + .order_by(cs_bucket) + )).all() + return wan, clients + + @unifi_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): @@ -58,23 +110,7 @@ async def rows(): b_secs = bucket_seconds(since) async with current_app.db_sessionmaker() as db: - # WAN history — bucketed for accurate full-range sparklines - wan_bucket = ( - cast(func.strftime('%s', UnifiWanStat.scraped_at), Integer) / b_secs - ).label("bucket") - result = await db.execute( - select( - func.avg(UnifiWanStat.latency_ms).label("latency_ms"), - func.avg(UnifiWanStat.rx_bytes_rate).label("rx_bytes_rate"), - func.avg(UnifiWanStat.tx_bytes_rate).label("tx_bytes_rate"), - func.min(UnifiWanStat.scraped_at).label("scraped_at"), - wan_bucket, - ) - .where(UnifiWanStat.scraped_at >= since) - .group_by(wan_bucket) - .order_by(wan_bucket) - ) - wan_history = result.all() + wan_history, client_history = await history(db, since, b_secs) # Latest WAN — always most recent raw row for current displayed values result = await db.execute( @@ -92,25 +128,10 @@ async def rows(): # All devices result = await db.execute( - select(UnifiDevice).order_by(UnifiDevice.state.desc(), UnifiDevice.name) + select(UnifiDevice) ) - devices = result.scalars().all() + devices = devices_in_order(result.scalars().all()) - # Client count history — bucketed - cs_bucket = ( - cast(func.strftime('%s', UnifiClientSnapshot.scraped_at), Integer) / b_secs - ).label("bucket") - result = await db.execute( - select( - func.avg(UnifiClientSnapshot.total_clients).label("total_clients"), - func.min(UnifiClientSnapshot.scraped_at).label("scraped_at"), - cs_bucket, - ) - .where(UnifiClientSnapshot.scraped_at >= since) - .group_by(cs_bucket) - .order_by(cs_bucket) - ) - client_history = result.all() # Latest WLAN stats (always most recent per SSID) result = await db.execute( @@ -149,6 +170,7 @@ async def rows(): latest_wan=latest_wan, latest_clients=latest_clients, devices=devices, + device_state=device_state, top_clients=top_clients, wlan_latest=wlan_latest, speedtest=speedtest, diff --git a/plugins/unifi/templates/unifi/rows.html b/plugins/unifi/templates/unifi/rows.html index aa1e7fb..b26adb9 100644 --- a/plugins/unifi/templates/unifi/rows.html +++ b/plugins/unifi/templates/unifi/rows.html @@ -50,7 +50,7 @@ {% for d in devices %}