fix(unifi): the Overview's history buckets on Postgres; devices name their state (#5207)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m35s
CI / publish (push) Successful in 1m29s

#5207 "UniFi Overview tab: func.strftime is SQLite-only": the WAN and
client sparklines bucketed with strftime('%s'), which Postgres lacks, so
/plugins/unifi/rows failed. Traefik's date_bin bucket moves to
core/time_range.time_bucket and both plugins use it. Devices list offline
and failing first, and a state other than online is named (Upgrading,
Adopting, Heartbeat missed...) instead of drawn as Down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:28:19 -04:00
co-authored by Claude Opus 5.5
parent 46a301652b
commit ea31deabf9
6 changed files with 145 additions and 49 deletions
+3 -12
View File
@@ -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"),
+58 -36
View File
@@ -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,
+7 -1
View File
@@ -50,7 +50,7 @@
{% for d in devices %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
{{ status_dot('up' if d.state == 1 else 'down') }}
{% set dot, label = device_state(d.state) %}{{ status_dot(dot, label) }}
<span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</td>
@@ -62,6 +62,12 @@
up {{ d.uptime_seconds|duration }}
</td>
</tr>
{% elif d.state != 1 %}
<tr>
<td colspan="3" style="padding:0 0 0.3rem 1.2rem;color:var(--text-dim);font-size:0.75rem;">
{{ label }}
</td>
</tr>
{% endif %}
{% endfor %}
</table>
+12
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TypeVar
from sqlalchemy import func
RANGES: dict[str, timedelta] = {
"1h": timedelta(hours=1),
"6h": timedelta(hours=6),
@@ -33,6 +35,16 @@ def bucket_seconds(since: datetime, target_points: int = 80) -> int:
return max(60, int(range_secs / target_points))
def time_bucket(column, seconds: int):
"""The start of the `seconds`-wide window a timestamp falls in, aligned to
the epoch, as a Postgres timestamp labelled "bucket". Group and order by
it. (An integer epoch divided by an int rendered as numeric division in
Postgres, so no two scrapes shared a bucket, and overflowed int4 after
2038 (#5256); strftime('%s') is SQLite-only (#5207).)"""
return func.date_bin(func.make_interval(0, 0, 0, 0, 0, 0, int(seconds)), column,
func.to_timestamp(0)).label("bucket")
T = TypeVar("T")
+52
View File
@@ -0,0 +1,52 @@
"""Integration: the UniFi overview's sparkline history buckets on Postgres
(#5207: it used SQLite's strftime, so the overview rows failed). Requires
STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import os
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_wan_and_client_history_average_per_window(app):
from sqlalchemy import delete
from plugins.unifi.models import UnifiClientSnapshot, UnifiWanStat
from plugins.unifi.routes import history
# Two scrapes in one 10-minute window, one in the next.
start = datetime(2031, 1, 1, 12, 0, tzinfo=timezone.utc)
at = [start + timedelta(minutes=1), start + timedelta(minutes=4), start + timedelta(minutes=12)]
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(UnifiWanStat).where(UnifiWanStat.scraped_at >= start))
await s.execute(delete(UnifiClientSnapshot).where(UnifiClientSnapshot.scraped_at >= start))
for when, ms, n in zip(at, (10.0, 30.0, 50.0), (4, 6, 9)):
s.add(UnifiWanStat(scraped_at=when, is_up=True, latency_ms=ms,
rx_bytes_rate=1.0, tx_bytes_rate=2.0))
s.add(UnifiClientSnapshot(scraped_at=when, total_clients=n))
try:
async with s.begin():
return await history(s, start, 600)
finally:
async with s.begin():
await s.execute(delete(UnifiWanStat).where(UnifiWanStat.scraped_at >= start))
await s.execute(delete(UnifiClientSnapshot).where(UnifiClientSnapshot.scraped_at >= start))
wan, clients = asyncio.run(go())
assert [(r.latency_ms, r.scraped_at, r.bucket) for r in wan] == [
(20.0, at[0], start), (50.0, at[2], start + timedelta(minutes=10))]
assert [float(r.total_clients) for r in clients] == [5.0, 9.0]
+13
View File
@@ -3,6 +3,7 @@ by Settings → Thresholds, devices with status dots, categories as badges (#512
from datetime import datetime, timezone
from types import SimpleNamespace as NS
from plugins.unifi.routes import device_state, devices_in_order
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
@@ -13,14 +14,19 @@ async def test_wan_health_and_devices_use_the_shared_shapes(app):
devices = [NS(name="switch", mac="aa", model="USW", device_type="usw", ip="10.0.0.2", state=1,
uptime_seconds=3600),
NS(name="ap-attic", mac="bb", model="U6", device_type="uap", ip="10.0.0.3", state=0,
uptime_seconds=None),
NS(name="ap-porch", mac="cc", model="U6", device_type="uap", ip="10.0.0.4", state=4,
uptime_seconds=None)]
async with app.test_request_context("/plugins/unifi/rows"):
page = await render_plugin_template(app, "unifi", "unifi/rows.html", latest_wan=wan, devices=devices,
device_state=device_state,
sparkline_latency="", sparkline_rx="", sparkline_tx="")
assert '<span class="stat-tile-label">WAN</span>' in page and "stat-crit" in page
assert '<span class="stat-tile-value">250 ms</span>' in page
assert 'class="dot dot-ok"' in page and 'class="dot dot-crit"' in page
# #5207: a device mid-upgrade is named, not drawn as down.
assert 'class="dot dot-warn" aria-hidden="true" title="Upgrading"' in page and "Offline" in page
assert "font-weight:600" not in page and "font-weight:700" not in page
assert not hex_colours(page), hex_colours(page)
@@ -32,3 +38,10 @@ async def test_event_categories_are_badges(app):
page = await render_plugin_template(app, "unifi", "unifi/events.html", events=events)
assert '<span class="badge badge-crit">ids</span>' in page
assert "rgba(" not in page
def test_offline_devices_come_first_then_transitions_then_working():
devices = [NS(name=n, mac=n, state=st) for n, st in
[("b-ok", 1), ("a-ok", 1), ("upgrading", 4), ("gone", 0), ("odd", 99)]]
assert [d.name for d in devices_in_order(devices)] == ["gone", "odd", "upgrading", "a-ok", "b-ok"]
assert device_state(99) == ("unknown", "State 99")