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) # ---------------------------------------------------------------------------