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`