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.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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user