feat: route badge reflects 7-day rolling average

The overall health badge on route cards now shows the rolling 7-day
average tier instead of the latest window-hours snapshot, so flapping
routes that are currently up still appear marginal/failing if the
week's mean warrants it. Same averaging drives the dashboard Route
Health widget's summary dot, the routes page summary strip counts,
and (already) the Route Trends chart line colors.

- compute_average_quality() in collector/routes.py (0/1/2 mean,
  thresholds 1.5/0.75, empty-history fallback) — kept in sync with
  the averageRouteTier JS helper in charts.js
- RouteRead / RouteDetail gain a 'quality_avg' field
- list/get/update handlers compute it per route; create skips (no
  meaningful history yet) and the frontend falls back to
  route_result.quality for brand-new routes
- diagnosis tooltip unchanged (still current-snapshot state text)
This commit is contained in:
Louis King
2026-07-19 17:23:54 +01:00
parent 37f8d7ae0b
commit fc60cd201a
8 changed files with 339 additions and 33 deletions
+21 -4
View File
@@ -1,6 +1,7 @@
"""Route health monitoring API routes."""
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
@@ -15,6 +16,7 @@ from meshcore_hub.api.channel_visibility import (
)
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.collector.routes import (
compute_average_quality,
derive_expected_hash,
evaluate_route,
evaluate_route_history,
@@ -84,7 +86,7 @@ def _result_to_summary(result: RouteResult | None) -> RouteResultSummary | None:
)
def _route_to_read(route: Route) -> RouteRead:
def _route_to_read(route: Route, *, quality_avg: Optional[str] = None) -> RouteRead:
return RouteRead(
id=route.id,
from_label=route.from_label,
@@ -101,11 +103,26 @@ def _route_to_read(route: Route) -> RouteRead:
route_nodes=[_route_node_to_read(rn) for rn in route.route_nodes],
route_observers=[_route_observer_to_read(ro) for ro in route.route_observers],
route_result=_result_to_summary(route.route_result),
quality_avg=quality_avg,
created_at=route.created_at,
updated_at=route.updated_at,
)
def _compute_quality_avg(session: DbSession, route: Route) -> Optional[str]:
"""Rolling 7-day average quality for the route badge.
Returns ``None`` for disabled routes. Empty history falls back to the
latest ``route_result.quality`` so brand-new routes don't flash a
misleading failing badge before their first evaluation cycle.
"""
if not route.enabled:
return None
history = evaluate_route_history(session, route, 7, include_today=True)
fallback = route.route_result.quality if route.route_result else None
return compute_average_quality(history, fallback=fallback)
def _resolve_nodes_by_pubkey(session: DbSession, public_keys: list[str]) -> list[Node]:
"""Resolve node public keys to Node objects, preserving input order."""
lowered = [pk.strip().lower() for pk in public_keys]
@@ -180,7 +197,7 @@ def list_routes(
routes = session.execute(select(Route).order_by(Route.from_label)).scalars().all()
filtered = [
_route_to_read(r)
_route_to_read(r, quality_avg=_compute_quality_avg(session, r))
for r in routes
if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level
]
@@ -294,7 +311,7 @@ def get_route(
for oid, cnt in contributing.items()
]
read = _route_to_read(route)
read = _route_to_read(route, quality_avg=_compute_quality_avg(session, route))
return RouteDetail(
**read.model_dump(),
contributing_observers=contributors,
@@ -410,7 +427,7 @@ def update_route(
session.refresh(route)
_reevaluate_route(session, route)
invalidate_routes(request)
return _route_to_read(route)
return _route_to_read(route, quality_avg=_compute_quality_avg(session, route))
@router.delete("/{route_id}", status_code=204)
+46
View File
@@ -738,6 +738,52 @@ def evaluate_route_history(
return results
# Thresholds for ``compute_average_quality`` — kept in sync with the
# ``averageTier`` helper in ``web/static/js/charts.js`` so the server-side
# rolling-average badge matches the chart's per-route line color.
AVERAGE_QUALITY_CLEAR_AT = 1.5
AVERAGE_QUALITY_MARGINAL_AT = 0.75
def compute_average_quality(
history: list[tuple[date, str, str, int]],
*,
fallback: Optional[str] = None,
) -> str:
"""Average per-day quality over a history window.
Maps each day's quality onto a 0/1/2 scale (failing < marginal < clear);
``no_coverage`` / ``unknown`` / ``disabled`` / ``None`` all collapse to
0, matching the merged-3-tier design used by the dashboard trend chart.
Returns ``clear`` / ``marginal`` / ``failing`` based on the mean:
mean >= 1.5 -> clear
mean >= 0.75 -> marginal
else -> failing
Empty history returns *fallback* (or ``"failing"`` if also ``None``) so
brand-new routes don't flash a misleading failing badge before their
first evaluation cycle.
"""
if not history:
return fallback or RouteQuality.FAILING.value
total = 0.0
for _d, quality, _s, _c in history:
if quality == RouteQuality.CLEAR.value:
total += 2.0
elif quality == RouteQuality.MARGINAL.value:
total += 1.0
# failing / unknown / no_coverage / disabled / None -> 0
mean = total / len(history)
if mean >= AVERAGE_QUALITY_CLEAR_AT:
return RouteQuality.CLEAR.value
if mean >= AVERAGE_QUALITY_MARGINAL_AT:
return RouteQuality.MARGINAL.value
return RouteQuality.FAILING.value
def evaluate_all_routes(
session: Session, now: datetime
) -> dict[str, tuple[str, str, int]]:
+15
View File
@@ -154,6 +154,14 @@ class RouteRead(BaseModel):
route_nodes: list[RouteNodeRead] = []
route_observers: list[RouteObserverRead] = []
route_result: Optional[RouteResultSummary] = None
quality_avg: Optional[str] = Field(
default=None,
description=(
"Rolling 7-day average quality (clear/marginal/failing). "
"Null when the route is disabled or has no history yet; falls "
"back to ``route_result.quality`` for brand-new routes."
),
)
created_at: datetime
updated_at: datetime
@@ -202,6 +210,13 @@ class RouteDetail(BaseModel):
route_nodes: list[RouteNodeRead] = []
route_observers: list[RouteObserverRead] = []
route_result: Optional[RouteResultSummary] = None
quality_avg: Optional[str] = Field(
default=None,
description=(
"Rolling 7-day average quality (clear/marginal/failing). "
"Null when the route is disabled or has no history yet."
),
)
contributing_observers: list[ContributingObserver] = []
recent_matches: list[RecentMatchPath] = []
created_at: datetime
+44 -26
View File
@@ -324,6 +324,48 @@ function createStackedBarChart(canvasId, buckets, colors) {
});
}
/**
* Map a route-quality enum value to the merged 3-tier space used by the
* dashboard trend chart and Route Health widget.
*
* ``clear`` clear
* ``marginal`` marginal
* anything else failing (covers ``failing``, ``unknown``,
* ``no_coverage``, ``disabled``, null)
*/
function routeQualityToTier(q) {
if (q === 'clear') return 'clear';
if (q === 'marginal') return 'marginal';
return 'failing';
}
/**
* Mean tier over the displayed window. Maps the 3-tier space onto a
* 0/1/2 numeric scale (failing < marginal < clear), averages, then
* buckets back: >=1.5 clear, >=0.75 marginal, else failing.
* Empty history falls through to failing (matches routeQualityToTier's
* default for unknown / null quality).
*
* Kept in sync with ``compute_average_quality`` in
* ``src/meshcore_hub/collector/routes.py`` so the server-side rolling
* badge matches the client-side chart line color.
*
* @param {Array<{quality: string}>|null} history
* @returns {string} tier name (``clear`` / ``marginal`` / ``failing``)
*/
function averageRouteTier(history) {
if (!history || history.length === 0) return 'failing';
var sum = 0;
for (var i = 0; i < history.length; i++) {
var tier = routeQualityToTier(history[i].quality);
sum += (tier === 'clear' ? 2 : tier === 'marginal' ? 1 : 0);
}
var mean = sum / history.length;
if (mean >= 1.5) return 'clear';
if (mean >= 0.75) return 'marginal';
return 'failing';
}
/**
* Create a multi-line route-status trend chart for the dashboard.
*
@@ -358,34 +400,10 @@ function createRoutesTrendChart(canvasId, routes, maxRoutes) {
// Bottom-to-top tier order on the categorical Y axis.
var tierOrder = ['failing', 'marginal', 'clear'];
function qualityToTier(q) {
if (q === 'clear') return 'clear';
if (q === 'marginal') return 'marginal';
return 'failing';
}
function tierColor(tier) {
return ChartColors.quality[tier] || ChartColors.quality.failing;
}
// Mean tier over the displayed window. Maps the 3-tier space onto a
// 0/1/2 numeric scale (failing < marginal < clear), averages, then
// buckets back: >=1.5 → clear, >=0.75 → marginal, else failing.
// Empty history falls through to failing (matches qualityToTier's
// default for unknown / null quality).
function averageTier(history) {
if (!history || history.length === 0) return 'failing';
var sum = 0;
for (var i = 0; i < history.length; i++) {
var tier = qualityToTier(history[i].quality);
sum += (tier === 'clear' ? 2 : tier === 'marginal' ? 1 : 0);
}
var mean = sum / history.length;
if (mean >= 1.5) return 'clear';
if (mean >= 0.75) return 'marginal';
return 'failing';
}
// Sort by current matched_count desc; routes with null matched_count
// (disabled / never evaluated) sort to the end.
var sorted = routes.slice().sort(function(a, b) {
@@ -407,10 +425,10 @@ function createRoutesTrendChart(canvasId, routes, maxRoutes) {
var datasets = top.map(function(entry) {
var history = entry.history || [];
var avgTier = averageTier(history);
var avgTier = averageRouteTier(history);
return {
label: entry.from_label + ' \u2192 ' + entry.to_label,
data: history.map(function(d) { return qualityToTier(d.quality); }),
data: history.map(function(d) { return routeQualityToTier(d.quality); }),
borderColor: tierColor(avgTier),
backgroundColor: 'transparent',
fill: false,
@@ -149,7 +149,15 @@ function renderRoutesHealth(routes) {
<div class="route-health-cell"
style="background:${colorFor(d.quality)}"
title="${d.date} \u2014 ${labelFor(d.quality)} (${d.matched_count})"></div>`);
const current = r.quality || (r.enabled ? 'no_coverage' : 'disabled');
// Right-most dot = rolling 7-day average tier (same computation as
// the chart line color and the route-card badge on /routes). Falls
// back to the snapshot if history is missing (e.g. backend degraded).
const hist = r.history || [];
const avgTier = (window.averageRouteTier && hist.length > 0)
? window.averageRouteTier(hist)
: null;
const current = avgTier
|| (r.enabled ? (r.quality || 'no_coverage') : 'disabled');
return html`<div class="flex items-center gap-2">
<span class="flex-1 min-w-0 truncate text-sm"
title="${r.from_label} \u2192 ${r.to_label}">
@@ -52,7 +52,10 @@ function renderSummaryStrip(routes) {
const counts = { clear: 0, marginal: 0, failing: 0, no_coverage: 0, disabled: 0 };
for (const r of routes) {
if (!r.enabled) { counts.disabled++; continue; }
const q = r.route_result?.quality || 'unknown';
// Prefer the 7-day rolling average so the strip matches the card
// badges (which also display ``quality_avg``). Falls back to the
// latest snapshot for brand-new routes that have no history yet.
const q = r.quality_avg || r.route_result?.quality || 'unknown';
if (q === 'clear') counts.clear++;
else if (q === 'marginal') counts.marginal++;
else if (q === 'failing') counts.failing++;
@@ -116,7 +119,11 @@ function renderStatsRow(route) {
}
function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, packetsEnabled, history }) {
const q = route.route_result?.quality || 'unknown';
// Badge reflects the 7-day rolling average (``quality_avg``) rather
// than the latest snapshot, so a flapping route that's currently up
// still shows as marginal/failing if it's been mostly down. Falls
// back to the snapshot for brand-new routes with no history.
const q = route.quality_avg || route.route_result?.quality || 'unknown';
const badgeCls = qualityBadgeClass(q, route.enabled);
const label = qualityLabel(q, route.enabled);
const dot = qualityDot(q, route.enabled);
+108
View File
@@ -328,6 +328,114 @@ class TestGetRouteDetail:
assert second.json() == first_body
class TestRouteQualityAvg:
"""``quality_avg`` field — rolling 7-day average tier.
Backs the route card badge and summary strip counts on /routes so a
flapping route that's currently up still shows as marginal/failing if
the 7-day mean warrants it. See ``compute_average_quality`` in
``collector/routes.py`` for the algorithm.
"""
@staticmethod
def _make_route(session, *, enabled: bool = True, label: str = "Avg"):
# Use uuid-derived public_keys so concurrent tests using _sample_nodes
# (which always inserts "a"*64 / "b"*64) can't trip the unique
# constraint on Node.public_key during parallel xdist runs against
# the shared SQLite file.
suffix = uuid4().hex
keys = [(suffix[:32]).rjust(64, "0"), (suffix[32:64]).rjust(64, "0")]
nodes = [_make_node(session, k) for k in keys]
route = Route(from_label=label, to_label="Sink", enabled=enabled)
session.add(route)
session.flush()
for pos, n in enumerate(nodes):
session.add(
RouteNode(
route_id=route.id,
node_id=n.id,
position=pos,
expected_hash=n.public_key[:2].upper(),
)
)
session.commit()
return route
def test_present_on_list_for_enabled_routes(self, client_no_auth, api_db_session):
"""Each enabled route in the list response carries a computed tier.
With no traffic in the test DB every history bucket is no_coverage
which collapses to failing (mean 0); the point of the assertion is
that the field exists and is a valid tier, not the specific value.
"""
route = self._make_route(api_db_session, enabled=True, label="Enabled")
resp = client_no_auth.get("/api/v1/routes")
assert resp.status_code == 200
items = resp.json()["items"]
# Lookup by id — parallel tests in the same worker may leave
# other routes in the truncated-but-not-yet-reaped window.
matching = [i for i in items if i["id"] == str(route.id)]
assert len(matching) == 1
avg = matching[0]["quality_avg"]
assert avg in {"clear", "marginal", "failing"}
# No traffic in the test DB -> all no_coverage -> failing.
assert avg == "failing"
def test_none_for_disabled_routes(self, client_no_auth, api_db_session):
"""Disabled routes skip the computation; field is null.
Uses the per-route DETAIL endpoint rather than the list the
list query races with other xdist workers' ``_truncate_all``
teardown against the shared SQLite file (the conftest only
isolates Postgres backends, not SQLite). The detail endpoint
scopes the read to a single row so a parallel truncate either
takes the row (404, not a false-pass) or leaves it.
"""
route = self._make_route(api_db_session, enabled=False, label="Disabled")
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
assert resp.status_code == 200
assert resp.json()["quality_avg"] is None
def test_present_on_detail(self, client_no_auth, api_db_session):
"""Detail endpoint also exposes the rolling average."""
route = self._make_route(api_db_session, enabled=True, label="Detail")
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
assert resp.status_code == 200
assert resp.json()["quality_avg"] == "failing"
def test_none_on_create_response(self, client_no_auth, api_db_session):
"""Create handler skips the rolling computation.
A brand-new route has no meaningful 7-day history; the frontend
falls back to ``route_result.quality`` via the
``q = route.quality_avg || route.route_result?.quality`` chain.
"""
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_no_auth.post(
"/api/v1/routes",
json={
"from_label": "Fresh",
"to_label": "Route",
"node_public_keys": [n.public_key for n in nodes],
},
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 201
assert resp.json()["quality_avg"] is None
def test_computed_on_update_response(self, client_no_auth, api_db_session):
"""Update handler recomputes the average (history pre-exists)."""
route = self._make_route(api_db_session, enabled=True, label="UpdateMe")
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "now with description"},
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 200
assert resp.json()["quality_avg"] == "failing"
class TestUpdateRoute:
def test_update_from_to(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
+87
View File
@@ -8,6 +8,7 @@ from sqlalchemy import select
from meshcore_hub.collector.routes import (
_has_any_hops_per_day,
_route_expected_hashes,
compute_average_quality,
derive_expected_hash,
derive_quality,
detect_observed_widths,
@@ -1085,6 +1086,92 @@ class TestEvaluateRouteHistory:
assert today_entry[1] == RouteQuality.UNKNOWN.value
class TestComputeAverageQuality:
"""Rolling-average tier over a history window (server-side badge source).
Mirrors the ``averageRouteTier`` JS helper in
``web/static/js/charts.js`` so the route card badge matches the chart
line color when both render the same window.
"""
@staticmethod
def _day(day_offset: int, quality: str, matched: int = 0):
return (
datetime(2024, 1, 1, tzinfo=timezone.utc).date()
+ timedelta(days=day_offset),
quality,
"healthy" if quality != "unknown" else "no_coverage",
matched,
)
def test_all_clear(self):
history = [self._day(i, RouteQuality.CLEAR.value) for i in range(7)]
assert compute_average_quality(history) == RouteQuality.CLEAR.value
def test_all_marginal(self):
history = [self._day(i, RouteQuality.MARGINAL.value) for i in range(7)]
assert compute_average_quality(history) == RouteQuality.MARGINAL.value
def test_all_failing(self):
history = [self._day(i, RouteQuality.FAILING.value) for i in range(7)]
assert compute_average_quality(history) == RouteQuality.FAILING.value
def test_mixed_clear_marginal_yields_clear(self):
# mean = (2+1+2+1+2+1+2)/7 ≈ 1.57 → clear
history = [
self._day(0, RouteQuality.CLEAR.value),
self._day(1, RouteQuality.MARGINAL.value),
self._day(2, RouteQuality.CLEAR.value),
self._day(3, RouteQuality.MARGINAL.value),
self._day(4, RouteQuality.CLEAR.value),
self._day(5, RouteQuality.MARGINAL.value),
self._day(6, RouteQuality.CLEAR.value),
]
assert compute_average_quality(history) == RouteQuality.CLEAR.value
def test_half_clear_half_failing_yields_marginal(self):
# mean = (2+0+2+0+2+0+2)/7 ≈ 1.14 → marginal
history = [
self._day(
i,
RouteQuality.CLEAR.value if i % 2 == 0 else RouteQuality.FAILING.value,
)
for i in range(7)
]
assert compute_average_quality(history) == RouteQuality.MARGINAL.value
def test_quarter_clear_three_quarters_failing_yields_failing(self):
# mean = (2+0+0+0+2+0+0)/7 ≈ 0.57 → failing
qualities = [
RouteQuality.CLEAR.value,
RouteQuality.FAILING.value,
RouteQuality.FAILING.value,
RouteQuality.FAILING.value,
RouteQuality.CLEAR.value,
RouteQuality.FAILING.value,
RouteQuality.FAILING.value,
]
history = [self._day(i, q) for i, q in enumerate(qualities)]
assert compute_average_quality(history) == RouteQuality.FAILING.value
def test_unknown_collapses_to_failing(self):
# unknown is in the failing tier (0) — a route with no coverage
# for the whole window averages to failing, not "unknown"
history = [self._day(i, RouteQuality.UNKNOWN.value) for i in range(7)]
assert compute_average_quality(history) == RouteQuality.FAILING.value
def test_empty_history_uses_fallback(self):
# Brand-new routes (no history yet) should not flash a misleading
# failing badge — fall back to the current snapshot.
assert (
compute_average_quality([], fallback=RouteQuality.MARGINAL.value)
== RouteQuality.MARGINAL.value
)
def test_empty_history_defaults_to_failing(self):
assert compute_average_quality([]) == RouteQuality.FAILING.value
# ---------------------------------------------------------------------------
# Branch-coverage tests (guards, exception handlers, optional-param paths)
# ---------------------------------------------------------------------------