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
+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>