From fc60cd201a804ed05f5d841d88ba33e5af103fb7 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 17:23:54 +0100 Subject: [PATCH] feat: route badge reflects 7-day rolling average MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/meshcore_hub/api/routes/routes.py | 25 +++- src/meshcore_hub/collector/routes.py | 46 ++++++++ src/meshcore_hub/common/schemas/routes.py | 15 +++ src/meshcore_hub/web/static/js/charts.js | 70 +++++++----- .../web/static/js/spa/pages/dashboard.js | 10 +- .../web/static/js/spa/pages/routes.js | 11 +- tests/test_api/test_routes.py | 108 ++++++++++++++++++ tests/test_collector/test_routes.py | 87 ++++++++++++++ 8 files changed, 339 insertions(+), 33 deletions(-) diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index cad1d0f..45c5510 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -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) diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py index af70ab6..ce79624 100644 --- a/src/meshcore_hub/collector/routes.py +++ b/src/meshcore_hub/collector/routes.py @@ -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]]: diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py index 8864311..9a7a8f4 100644 --- a/src/meshcore_hub/common/schemas/routes.py +++ b/src/meshcore_hub/common/schemas/routes.py @@ -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 diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js index c0c1fb6..2dba18c 100644 --- a/src/meshcore_hub/web/static/js/charts.js +++ b/src/meshcore_hub/web/static/js/charts.js @@ -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, diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index b884e45..11da706 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -149,7 +149,15 @@ function renderRoutesHealth(routes) {
`); - 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`
diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js index e02b5b2..7d1dac1 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -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); diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 8668ec2..2a668c8 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -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) diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py index c0d09ec..11aba94 100644 --- a/tests/test_collector/test_routes.py +++ b/tests/test_collector/test_routes.py @@ -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) # ---------------------------------------------------------------------------