fix(unifi): the Overview's history buckets on Postgres; devices name their state (#5207)
#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:
@@ -10,7 +10,7 @@ import time
|
|||||||
|
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
from steward.models.users import UserRole
|
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.core.formatting import cert_left, status_class
|
||||||
from steward.models.metrics import PluginMetricLatest
|
from steward.models.metrics import PluginMetricLatest
|
||||||
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
|
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
|
||||||
@@ -430,15 +430,6 @@ async def widget_access_log():
|
|||||||
summary=summary, widget_id=widget_id)
|
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
|
# 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).
|
# phase, so a slow query names itself (#5355: 8 s on every poll, warm).
|
||||||
SLOW_PAGE_SECONDS = 1.0
|
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}
|
history: dict[str, list] = {n: [] for n in names}
|
||||||
if not history:
|
if not history:
|
||||||
return 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(
|
result = await db.execute(
|
||||||
select(
|
select(
|
||||||
TraefikMetric.router_name,
|
TraefikMetric.router_name,
|
||||||
@@ -509,7 +500,7 @@ async def request_history(db, since, bucket_seconds: int) -> list[dict]:
|
|||||||
.group_by(TraefikMetric.scraped_at)
|
.group_by(TraefikMetric.scraped_at)
|
||||||
.subquery()
|
.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(
|
rows = (await db.execute(
|
||||||
select(
|
select(
|
||||||
func.avg(per_scrape.c.req).label("req_rate"),
|
func.avg(per_scrape.c.req).label("req_rate"),
|
||||||
|
|||||||
+58
-36
@@ -2,11 +2,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from quart import Blueprint, current_app, render_template, request
|
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.auth.middleware import require_role
|
||||||
from steward.models.users import UserRole
|
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 (
|
from .models import (
|
||||||
UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiDpiSnapshot,
|
UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiDpiSnapshot,
|
||||||
UnifiEvent, UnifiFwRule, UnifiKnownClient, UnifiNetwork,
|
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")
|
@unifi_bp.get("/rows")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def rows():
|
async def rows():
|
||||||
@@ -58,23 +110,7 @@ async def rows():
|
|||||||
b_secs = bucket_seconds(since)
|
b_secs = bucket_seconds(since)
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
# WAN history — bucketed for accurate full-range sparklines
|
wan_history, client_history = await history(db, since, b_secs)
|
||||||
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()
|
|
||||||
|
|
||||||
# Latest WAN — always most recent raw row for current displayed values
|
# Latest WAN — always most recent raw row for current displayed values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -92,25 +128,10 @@ async def rows():
|
|||||||
|
|
||||||
# All devices
|
# All devices
|
||||||
result = await db.execute(
|
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)
|
# Latest WLAN stats (always most recent per SSID)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -149,6 +170,7 @@ async def rows():
|
|||||||
latest_wan=latest_wan,
|
latest_wan=latest_wan,
|
||||||
latest_clients=latest_clients,
|
latest_clients=latest_clients,
|
||||||
devices=devices,
|
devices=devices,
|
||||||
|
device_state=device_state,
|
||||||
top_clients=top_clients,
|
top_clients=top_clients,
|
||||||
wlan_latest=wlan_latest,
|
wlan_latest=wlan_latest,
|
||||||
speedtest=speedtest,
|
speedtest=speedtest,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
|
<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>
|
<span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</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 }}
|
up {{ d.uptime_seconds|duration }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
RANGES: dict[str, timedelta] = {
|
RANGES: dict[str, timedelta] = {
|
||||||
"1h": timedelta(hours=1),
|
"1h": timedelta(hours=1),
|
||||||
"6h": timedelta(hours=6),
|
"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))
|
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")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -3,6 +3,7 @@ by Settings → Thresholds, devices with status dots, categories as badges (#512
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace as NS
|
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._templates import render_plugin_template
|
||||||
from tests.test_style_tokens import hex_colours
|
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,
|
devices = [NS(name="switch", mac="aa", model="USW", device_type="usw", ip="10.0.0.2", state=1,
|
||||||
uptime_seconds=3600),
|
uptime_seconds=3600),
|
||||||
NS(name="ap-attic", mac="bb", model="U6", device_type="uap", ip="10.0.0.3", state=0,
|
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)]
|
uptime_seconds=None)]
|
||||||
async with app.test_request_context("/plugins/unifi/rows"):
|
async with app.test_request_context("/plugins/unifi/rows"):
|
||||||
page = await render_plugin_template(app, "unifi", "unifi/rows.html", latest_wan=wan, devices=devices,
|
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="")
|
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-label">WAN</span>' in page and "stat-crit" in page
|
||||||
assert '<span class="stat-tile-value">250 ms</span>' 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
|
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 "font-weight:600" not in page and "font-weight:700" not in page
|
||||||
assert not hex_colours(page), hex_colours(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)
|
page = await render_plugin_template(app, "unifi", "unifi/events.html", events=events)
|
||||||
assert '<span class="badge badge-crit">ids</span>' in page
|
assert '<span class="badge badge-crit">ids</span>' in page
|
||||||
assert "rgba(" not 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user