From 37f8d7ae0bcf27396b4b0b8b7f98a59b40043047 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 16:49:19 +0100 Subject: [PATCH 1/2] feat: dashboard route widgets + cache TTL consolidation Adds Route Trends chart and Route Health strip grid to the dashboard, backs them with a new GET /dashboard/routes-overview endpoint, and consolidates the Redis cache layer (removes ROUTE_DETAIL TTL, raises DASHBOARD default to 1h, splits recent-activity into its own short-TTL endpoint so Recent panels stay fresh while aggregate counts cache 1h). --- .env.example | 11 +- docker-compose.yml | 3 +- docs/configuration.md | 5 +- docs/upgrading.md | 35 +- src/meshcore_hub/api/app.py | 9 +- src/meshcore_hub/api/cache_invalidation.py | 6 + src/meshcore_hub/api/cli.py | 19 +- src/meshcore_hub/api/routes/dashboard.py | 278 +++++++++-- src/meshcore_hub/api/routes/routes.py | 2 +- src/meshcore_hub/collector/routes.py | 40 +- src/meshcore_hub/common/config.py | 13 +- src/meshcore_hub/common/schemas/messages.py | 33 +- src/meshcore_hub/common/schemas/routes.py | 35 ++ src/meshcore_hub/web/static/css/app.css | 14 + src/meshcore_hub/web/static/js/charts.js | 144 +++++- src/meshcore_hub/web/static/js/spa/app.js | 8 +- .../web/static/js/spa/components.js | 4 +- .../web/static/js/spa/pages/dashboard.js | 108 +++- .../web/static/js/spa/pages/home.js | 12 +- .../web/static/js/spa/pages/routes.js | 4 +- src/meshcore_hub/web/static/locales/en.json | 13 +- src/meshcore_hub/web/static/locales/nl.json | 13 +- src/meshcore_hub/web/templates/spa.html | 6 +- tests/test_api/test_cache.py | 39 +- tests/test_api/test_dashboard.py | 461 ++++++++++++++++-- tests/test_api/test_routes.py | 61 ++- tests/test_collector/test_routes.py | 85 ++++ 27 files changed, 1241 insertions(+), 220 deletions(-) diff --git a/.env.example b/.env.example index 0148eed..4a950cc 100644 --- a/.env.example +++ b/.env.example @@ -411,11 +411,12 @@ PROMETHEUS_PORT=9090 # Default cache TTL in seconds (matches web auto-refresh interval) # REDIS_CACHE_TTL=30 -# Cache TTL for dashboard endpoints (seconds; covers /dashboard/* and /routes/{id}/history) -# REDIS_CACHE_TTL_DASHBOARD=300 - -# Cache TTL for /routes/{id} detail endpoint (seconds) -# REDIS_CACHE_TTL_ROUTE_DETAIL=300 +# Cache TTL for dashboard endpoints, /routes/{id} detail, and per-route +# health history (seconds). Trend/aggregation data tolerates much longer +# staleness than the default TTL. The Recent Adverts / Recent Channel +# Messages widgets live on a separate /dashboard/recent-activity endpoint +# that always uses REDIS_CACHE_TTL (above) so they stay fresh. +# REDIS_CACHE_TTL_DASHBOARD=3600 # Emit HTTP Cache-Control on /api/v1/* responses + ETag/If-None-Match on # cached endpoints. The policy is `private, no-cache` on GETs (forces diff --git a/docker-compose.yml b/docker-compose.yml index 531fb57..b756362 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -320,8 +320,7 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_KEY_PREFIX=${REDIS_KEY_PREFIX:-hub} - REDIS_CACHE_TTL=${REDIS_CACHE_TTL:-30} - - REDIS_CACHE_TTL_DASHBOARD=${REDIS_CACHE_TTL_DASHBOARD:-300} - - REDIS_CACHE_TTL_ROUTE_DETAIL=${REDIS_CACHE_TTL_ROUTE_DETAIL:-300} + - REDIS_CACHE_TTL_DASHBOARD=${REDIS_CACHE_TTL_DASHBOARD:-3600} - API_CACHE_CONTROL_ENABLED=${API_CACHE_CONTROL_ENABLED:-true} # Spam detection: hide-filter switch + threshold (bridged from # FEATURE_SPAM_DETECTION so it tracks the collector and web toggle). On by diff --git a/docs/configuration.md b/docs/configuration.md index fec99c6..5304a3e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,9 +61,8 @@ Optional Redis-backed caching for API responses. When disabled or unavailable, t | `REDIS_DB` | `0` | Redis database number | | `REDIS_PASSWORD` | _(none)_ | Redis password (optional) | | `REDIS_KEY_PREFIX` | `hub` | Cache key prefix for multi-instance isolation | -| `REDIS_CACHE_TTL` | `30` | Default cache TTL in seconds | -| `REDIS_CACHE_TTL_DASHBOARD` | `300` | Cache TTL for `/dashboard/*` endpoints and `/routes/{id}/history` (seconds). Trend/aggregation data tolerates longer staleness than the default TTL. | -| `REDIS_CACHE_TTL_ROUTE_DETAIL` | `300` | Cache TTL for `/routes/{id}` detail endpoint in seconds | +| `REDIS_CACHE_TTL` | `30` | Default cache TTL in seconds. Also covers `GET /dashboard/recent-activity` (Recent Adverts / Recent Channel Messages widgets) so those panels stay fresh independently of the longer dashboard TTL below. | +| `REDIS_CACHE_TTL_DASHBOARD` | `3600` | Cache TTL in seconds for `/dashboard/*` endpoints (except `/recent-activity`), `/routes/{id}` detail, and `/routes/{id}/history`. Trend/aggregation data tolerates much longer staleness than the default TTL. | ### HTTP Cache-Control diff --git a/docs/upgrading.md b/docs/upgrading.md index 0355f06..5201785 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -59,19 +59,34 @@ To suppress all client-side caching directives (e.g. for debugging): The `X-Cache: HIT|MISS` observability header is unaffected — it is still emitted whenever a request flows through the `@cached` decorator, regardless of this setting. -### Dashboard Cache TTL default raised (30s → 300s) +### Dashboard / Route cache consolidation (1h dashboard TTL, dedicated route-detail TTL removed) -The default for `REDIS_CACHE_TTL_DASHBOARD` has been raised from **30 s** to **300 s** (5 minutes). This setting covers all `/dashboard/*` endpoints (`stats`, `activity`, `packet-activity`, `packet-breakdown`, `message-activity`, `node-count`) **and** the per-route health strip `GET /api/v1/routes/{id}/history`. These all return trend/aggregation data where minute-level staleness is invisible to operators but every cache miss costs seconds of SQL/aggregation work — the old 30 s default was leaving most of that work unpaid on typical dashboards. +Two related changes to the Redis cache layer: -| Variable | Old default | New default | -| --- | --- | --- | -| `REDIS_CACHE_TTL_DASHBOARD` | `30` | `300` | +1. **`REDIS_CACHE_TTL_DASHBOARD` default raised from 300 s to 3600 s (1 hour).** This setting now covers all `/dashboard/*` endpoints **and** `/routes/{id}` detail **and** `/routes/{id}/history` — trend/aggregation data where minute-level staleness is invisible to operators but every cache miss costs seconds of SQL/aggregation work. The 5-minute window shipped earlier in v0.16.0 was still leaving most of that work unpaid on busy meshes. -- **Operators who already set the env var explicitly:** no change — your value still wins. -- **Operators on the default:** silently bumped to 5 min staleness on the dashboard and route health strips. If you relied on the old 30 s behaviour (e.g. for development or tight freshness SLOs), set `REDIS_CACHE_TTL_DASHBOARD=30` to restore it. -- **Existing Redis entries at deploy time:** keep their original TTL and expire naturally within ≤ 30 s. No flush is required. -- **Browser `max-age`:** the HTTP `Cache-Control: max-age=` emitted by the middleware follows the Redis TTL, so browser-side caching on these endpoints also extends to 5 min. Clicking around the dashboard will be instant for that window. A hard refresh (`Ctrl+Shift+R`) bypasses this if you need the latest data. -- **Side-effect fix:** the reported short-TTL behaviour on the per-route healthchart (which shares this setting) is resolved. +2. **`REDIS_CACHE_TTL_ROUTE_DETAIL` removed.** The dedicated 300 s TTL for `/routes/{id}` detail has been folded into `REDIS_CACHE_TTL_DASHBOARD` (so the route detail page now also serves from the 1 h cache). Operators who had set `REDIS_CACHE_TTL_ROUTE_DETAIL` explicitly should delete that line from their `.env` — the variable is now ignored. + +3. **`GET /dashboard/recent-activity` split out of `GET /dashboard/stats`.** The Recent Adverts and Recent Channel Messages widgets used to live as fields inside `/dashboard/stats`, so they inherited the long dashboard TTL and went stale for the full window. They now have their own endpoint, `/dashboard/recent-activity`, which is cached at the **default** `REDIS_CACHE_TTL` (30 s) — so the Recent panels stay fresh even while the aggregate counts alongside them are cached for an hour. The two fields were **removed from the `/dashboard/stats` response**: + + | Field | Old location | New location | + | --------------------------- | ------------------------- | ------------------------------------- | + | `recent_advertisements` | `/dashboard/stats` field | `/dashboard/recent-activity` field | + | `channel_messages` | `/dashboard/stats` field | `/dashboard/recent-activity` field | + + `/dashboard/stats` still returns `channel_message_counts` (the per-channel counts); only the message bodies moved. The SPA's dashboard page now fires one extra parallel request to `/dashboard/recent-activity`. `home.js` only used the counts and needs no change — it silently benefits from the longer `/dashboard/stats` TTL. + +| Variable | Old default | New default | +| ----------------------------- | ----------- | ----------- | +| `REDIS_CACHE_TTL_DASHBOARD` | `300` | `3600` | +| `REDIS_CACHE_TTL_ROUTE_DETAIL`| `300` | _(removed)_ | + +- **Operators who already set `REDIS_CACHE_TTL_DASHBOARD` explicitly:** no change — your value still wins. +- **Operators on the default:** silently bumped from 5 min to 1 h staleness on dashboard aggregates, route detail, and route health strips. If you relied on the previous 5 min freshness (e.g. for development), set `REDIS_CACHE_TTL_DASHBOARD=300` (or lower) to restore it. +- **Operators who set `REDIS_CACHE_TTL_ROUTE_DETAIL`:** delete the line — the variable is now ignored, the route detail page uses `REDIS_CACHE_TTL_DASHBOARD` like everything else in this group. +- **Existing Redis entries at deploy time:** keep their original TTL and expire naturally. No flush is required. +- **Browser `max-age`:** the HTTP `Cache-Control: max-age=` emitted by the middleware follows the Redis TTL, so browser-side caching on these endpoints also extends to 1 h. The Recent Adverts / Recent Channel Messages widgets are unaffected because `/dashboard/recent-activity` still uses the short default TTL. +- **External API consumers** reading `recent_advertisements` / `channel_messages` from `/dashboard/stats`: switch to `/dashboard/recent-activity` (same field shapes, same role-visibility filtering). ## v0.15.0 diff --git a/src/meshcore_hub/api/app.py b/src/meshcore_hub/api/app.py index 4b358ff..a2569bc 100644 --- a/src/meshcore_hub/api/app.py +++ b/src/meshcore_hub/api/app.py @@ -90,8 +90,7 @@ def create_app( redis_password: str | None = None, redis_key_prefix: str = "hub", redis_cache_ttl: int = 30, - redis_cache_ttl_dashboard: int = 300, - redis_cache_ttl_route_detail: int = 300, + redis_cache_ttl_dashboard: int = 3600, api_cache_control_enabled: bool = True, spam_detection_enabled: bool = False, spam_score_threshold: float = 0.65, @@ -120,8 +119,8 @@ def create_app( redis_password: Redis password (optional) redis_key_prefix: Prefix for all cache keys redis_cache_ttl: Default cache TTL in seconds - redis_cache_ttl_dashboard: Cache TTL for dashboard endpoints - redis_cache_ttl_route_detail: Cache TTL for /routes/{id} detail endpoint + redis_cache_ttl_dashboard: Cache TTL in seconds for dashboard endpoints, + /routes/{id} detail, and per-route health history api_cache_control_enabled: Emit HTTP Cache-Control on /api/v1/* and ETag/If-None-Match handling on @cached endpoints. @@ -159,7 +158,6 @@ def create_app( app.state.redis_key_prefix = redis_key_prefix app.state.redis_cache_ttl = redis_cache_ttl app.state.redis_cache_ttl_dashboard = redis_cache_ttl_dashboard - app.state.redis_cache_ttl_route_detail = redis_cache_ttl_route_detail app.state.api_cache_control_enabled = api_cache_control_enabled app.state.spam_detection_enabled = spam_detection_enabled app.state.spam_score_threshold = spam_score_threshold @@ -338,7 +336,6 @@ def create_app_from_env() -> FastAPI: redis_key_prefix=settings.redis_key_prefix, redis_cache_ttl=settings.redis_cache_ttl, redis_cache_ttl_dashboard=settings.redis_cache_ttl_dashboard, - redis_cache_ttl_route_detail=settings.redis_cache_ttl_route_detail, api_cache_control_enabled=settings.api_cache_control_enabled, spam_detection_enabled=settings.spam_detection_enabled, spam_score_threshold=settings.spam_score_threshold, diff --git a/src/meshcore_hub/api/cache_invalidation.py b/src/meshcore_hub/api/cache_invalidation.py index d198587..62d0ac7 100644 --- a/src/meshcore_hub/api/cache_invalidation.py +++ b/src/meshcore_hub/api/cache_invalidation.py @@ -89,8 +89,14 @@ def invalidate_routes(request: Request) -> None: All three endpoints share the ``/api/v1/routes`` URL-path prefix in their cache keys (the ``{id}`` and ``{id}/history`` sub-paths glob-match the same SCAN), so a single ``delete`` covers them. + + The dashboard's ``GET /dashboard/routes-overview`` endpoint also embeds + per-route state and history, so it must be invalidated alongside the + per-route caches. That key lives under the ``dashboard/routes-overview`` + endpoint-name namespace (no ``key_builder``), so it needs its own drop. """ _drop(request, "/api/v1/routes") + _drop(request, "dashboard/routes-overview") def invalidate_nodes(request: Request) -> None: diff --git a/src/meshcore_hub/api/cli.py b/src/meshcore_hub/api/cli.py index 256ea36..d8dd0fc 100644 --- a/src/meshcore_hub/api/cli.py +++ b/src/meshcore_hub/api/cli.py @@ -173,16 +173,12 @@ import click @click.option( "--redis-cache-ttl-dashboard", type=int, - default=300, + default=3600, envvar="REDIS_CACHE_TTL_DASHBOARD", - help="Cache TTL for dashboard endpoints (seconds)", -) -@click.option( - "--redis-cache-ttl-route-detail", - type=int, - default=300, - envvar="REDIS_CACHE_TTL_ROUTE_DETAIL", - help="Cache TTL for /routes/{id} detail endpoint (seconds)", + help=( + "Cache TTL in seconds for dashboard endpoints, /routes/{id} detail, " + "and per-route health history" + ), ) @click.option( "--api-cache-control-enabled/--no-api-cache-control", @@ -239,7 +235,6 @@ def api( redis_key_prefix: str, redis_cache_ttl: int, redis_cache_ttl_dashboard: int, - redis_cache_ttl_route_detail: int, api_cache_control_enabled: bool, reload: bool, workers: int, @@ -299,8 +294,7 @@ def api( click.echo(f"Redis key prefix: {redis_key_prefix}") click.echo( f"Redis cache TTL: {redis_cache_ttl}s " - f"(dashboard: {redis_cache_ttl_dashboard}s, " - f"route detail: {redis_cache_ttl_route_detail}s)" + f"(dashboard: {redis_cache_ttl_dashboard}s)" ) click.echo(f"API Cache-Control enabled: {api_cache_control_enabled}") click.echo(f"Reload mode: {reload}") @@ -364,7 +358,6 @@ def api( redis_key_prefix=redis_key_prefix, redis_cache_ttl=redis_cache_ttl, redis_cache_ttl_dashboard=redis_cache_ttl_dashboard, - redis_cache_ttl_route_detail=redis_cache_ttl_route_detail, api_cache_control_enabled=api_cache_control_enabled, ) diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 680e10c..0dcf832 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -9,6 +9,7 @@ from sqlalchemy.sql.elements import ColumnElement from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.cache import cached, sorted_query_string from meshcore_hub.api.channel_visibility import ( + VISIBILITY_LEVELS, get_max_visibility_level, get_visible_channel_indices, resolve_user_role, @@ -18,12 +19,15 @@ from meshcore_hub.api.observer_utils import ( fetch_observers_for_events, resolve_sender_names, ) +from meshcore_hub.collector.routes import evaluate_route_history +from meshcore_hub.common.config import get_collector_settings from meshcore_hub.common.models import ( Advertisement, Message, Node, NodeTag, RawPacket, + Route, UserProfile, ) from meshcore_hub.common.schemas.messages import ( @@ -35,8 +39,14 @@ from meshcore_hub.common.schemas.messages import ( MessageActivity, NodeCountHistory, PacketBreakdown, + RecentActivity, RecentAdvertisement, ) +from meshcore_hub.common.schemas.routes import ( + RouteDayQuality, + RouteOverviewEntry, + RoutesOverview, +) router = APIRouter() @@ -53,6 +63,29 @@ def _dashboard_msg_activity_key_builder(request: Request) -> str: return f"dashboard/message-activity:role={role}:{sorted_query_string(request)}" +def _dashboard_recent_activity_key_builder(request: Request) -> str: + """Role-scoped key for ``GET /dashboard/recent-activity``. + + The response filters channel messages by visibility, so the cache key + must vary by role — otherwise an anonymous GET would fill the cache + with a redacted response served to a subsequent admin GET. + """ + role = resolve_user_role(request) or "anonymous" + return f"dashboard/recent-activity:role={role}:{sorted_query_string(request)}" + + +def _dashboard_routes_overview_key_builder(request: Request) -> str: + """Role-scoped key for ``GET /dashboard/routes-overview``. + + The endpoint filters admin/operator-only routes by visibility, so the + cache key must vary by role — otherwise an anonymous GET could fill the + cache with a response that hides admin routes, and a subsequent admin + GET would receive that same redacted response. + """ + role = resolve_user_role(request) or "anonymous" + return f"dashboard/routes-overview:role={role}:{sorted_query_string(request)}" + + def _flood_only_filter( ad_model: type[Advertisement], ) -> ColumnElement[bool]: @@ -166,6 +199,89 @@ def get_stats( total_packets = packet_row[0] or 0 packets_7d = packet_row[1] or 0 + # Channel message counts (only visible channels). The recent messages + # themselves live on /dashboard/recent-activity so they can carry a + # shorter cache TTL than these aggregate counts. + channel_counts_query = ( + select(Message.channel_idx, func.count()) + .where(Message.message_type == "channel") + .where(Message.channel_idx.isnot(None)) + .where(Message.channel_idx.in_(visible_indices)) + .group_by(Message.channel_idx) + ) + channel_results = session.execute(channel_counts_query).all() + channel_message_counts = { + int(channel): int(count) for channel, count in channel_results + } + + from meshcore_hub.common.config import get_web_settings + + web_settings = get_web_settings() + operator_role = web_settings.oidc_role_operator + member_role = web_settings.oidc_role_member + test_role = web_settings.oidc_role_test + + # Operator + member counts in one query. The optional test-role exclusion + # is folded into each conditional branch. + operator_cond = UserProfile.roles.contains(operator_role) + member_cond = UserProfile.roles.contains(member_role) + if test_role: + not_test = ~UserProfile.roles.contains(test_role) + operator_cond = and_(operator_cond, not_test) + member_cond = and_(member_cond, not_test) + + profile_row = session.execute( + select( + func.sum(case((operator_cond, 1), else_=0)), + func.sum(case((member_cond, 1), else_=0)), + ).select_from(UserProfile) + ).one() + total_operators = profile_row[0] or 0 + total_members = profile_row[1] or 0 + + return DashboardStats( + total_nodes=total_nodes, + active_nodes=active_nodes, + total_messages=total_messages, + messages_today=messages_today, + messages_7d=messages_7d, + total_advertisements=total_advertisements, + advertisements_24h=advertisements_24h, + advertisements_7d=advertisements_7d, + channel_message_counts=channel_message_counts, + total_operators=total_operators, + total_members=total_members, + total_packets=total_packets, + packets_7d=packets_7d, + ) + + +@router.get("/recent-activity", response_model=RecentActivity) +@cached( + "dashboard/recent-activity", + key_builder=_dashboard_recent_activity_key_builder, +) +def get_recent_activity( + _: RequireRead, + session: DbSession, + request: Request, +) -> RecentActivity: + """Get the recent-adverts and recent-channel-messages lists. + + Split out of ``GET /dashboard/stats`` so they can be cached at the + default ``redis_cache_ttl`` (30 s) instead of the much longer + ``redis_cache_ttl_dashboard`` that covers the aggregate counts — the + Recent Adverts / Recent Channel Messages panels are the only + dashboard widgets that need minute-level freshness. + + Role-visibility filtered the same way as the rest of the dashboard: + anonymous viewers see only public-channel messages, while operators + see every channel. Cache key is role-scoped accordingly. + """ + role = resolve_user_role(request) + max_level = get_max_visibility_level(role) + visible_indices = get_visible_channel_indices(session, max_level) + # Recent advertisements (last 10, flood-only) recent_ads = ( session.execute( @@ -241,22 +357,11 @@ def get_stats( for ad in recent_ads ] - # Channel message counts (only visible channels) - channel_counts_query = ( - select(Message.channel_idx, func.count()) - .where(Message.message_type == "channel") - .where(Message.channel_idx.isnot(None)) - .where(Message.channel_idx.in_(visible_indices)) - .group_by(Message.channel_idx) - ) - channel_results = session.execute(channel_counts_query).all() - channel_message_counts = { - int(channel): int(count) for channel, count in channel_results - } - - # Get latest 5 messages for each channel that has messages + # Channel messages for each visible channel (up to 5 latest each). + # The per-channel counts stay on /dashboard/stats (they're aggregate + # counts, not a "recent" list); only the message bodies live here. channel_messages: dict[int, list[ChannelMessage]] = {} - for channel_idx, _ in channel_results: + for channel_idx in visible_indices: messages_query = ( select(Message) .where(Message.message_type == "channel") @@ -265,6 +370,8 @@ def get_stats( .limit(5) ) channel_msgs = session.execute(messages_query).scalars().all() + if not channel_msgs: + continue # Look up sender names for these messages msg_prefixes = [m.pubkey_prefix for m in channel_msgs if m.pubkey_prefix] @@ -285,47 +392,9 @@ def get_stats( for m in channel_msgs ] - from meshcore_hub.common.config import get_web_settings - - web_settings = get_web_settings() - operator_role = web_settings.oidc_role_operator - member_role = web_settings.oidc_role_member - test_role = web_settings.oidc_role_test - - # Operator + member counts in one query. The optional test-role exclusion - # is folded into each conditional branch. - operator_cond = UserProfile.roles.contains(operator_role) - member_cond = UserProfile.roles.contains(member_role) - if test_role: - not_test = ~UserProfile.roles.contains(test_role) - operator_cond = and_(operator_cond, not_test) - member_cond = and_(member_cond, not_test) - - profile_row = session.execute( - select( - func.sum(case((operator_cond, 1), else_=0)), - func.sum(case((member_cond, 1), else_=0)), - ).select_from(UserProfile) - ).one() - total_operators = profile_row[0] or 0 - total_members = profile_row[1] or 0 - - return DashboardStats( - total_nodes=total_nodes, - active_nodes=active_nodes, - total_messages=total_messages, - messages_today=messages_today, - messages_7d=messages_7d, - total_advertisements=total_advertisements, - advertisements_24h=advertisements_24h, - advertisements_7d=advertisements_7d, + return RecentActivity( recent_advertisements=recent_advertisements, - channel_message_counts=channel_message_counts, channel_messages=channel_messages, - total_operators=total_operators, - total_members=total_members, - total_packets=total_packets, - packets_7d=packets_7d, ) @@ -633,3 +702,104 @@ def get_node_count_history( data.append(DailyActivityPoint(date=date_str, count=running)) return NodeCountHistory(days=days, data=data) + + +@router.get("/routes-overview", response_model=RoutesOverview) +@cached( + "dashboard/routes-overview", + ttl_setting="redis_cache_ttl_dashboard", + key_builder=_dashboard_routes_overview_key_builder, +) +def get_routes_overview( + _: RequireRead, + session: DbSession, + request: Request, + days: int = 7, +) -> RoutesOverview: + """Get an aggregate snapshot of route fleet health for the dashboard. + + Returns: + + - ``by_state``: route counts bucketed by their current ``state`` + (clear / marginal / failing / no_coverage / disabled). + - ``routes``: one entry per visible route carrying its current + ``state``/``quality``/``matched_count`` (from the latest + ``RouteResult`` written by the background evaluator) plus a + ``days``-long history (includes today) for the trend chart and + per-route strip grid. + + Role-visibility filtered the same way as ``GET /routes`` so members + don't see admin-only routes. ``days`` is clamped to the configured + raw-packet retention window so history queries can't scan purged data. + """ + days = min(days, 90) + retention = get_collector_settings().effective_raw_packet_retention_days + days = min(days, retention) + + role = resolve_user_role(request) + max_level = get_max_visibility_level(role) + + routes = session.execute(select(Route).order_by(Route.from_label)).scalars().all() + visible = [r for r in routes if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level] + + # State buckets — ``disabled`` for switched-off routes, otherwise the + # evaluator's last persisted state (falling back to ``no_coverage`` + # when no result exists yet, e.g. a freshly created route). + state_counts: dict[str, int] = {} + entries: list[RouteOverviewEntry] = [] + for route in visible: + history_tuples = evaluate_route_history( + session, route, days, include_today=True + ) + history = [ + RouteDayQuality(date=d, quality=q, state=s, matched_count=c) + for d, q, s, c in history_tuples + ] + + if not route.enabled: + state = "disabled" + quality = "disabled" + matched: int | None = None + elif route.route_result is not None: + state = route.route_result.state or "no_coverage" + quality = route.route_result.quality or "no_coverage" + matched = route.route_result.matched_count + else: + state = "no_coverage" + quality = "no_coverage" + matched = None + + state_counts[state] = state_counts.get(state, 0) + 1 + entries.append( + RouteOverviewEntry( + id=str(route.id), + from_label=route.from_label, + to_label=route.to_label, + visibility=route.visibility, + enabled=route.enabled, + state=state, + quality=quality, + matched_count=matched, + history=history, + ) + ) + + # Stable, UI-friendly bucket order. State values come from the + # evaluator's ``RouteState`` enum (``healthy`` / ``unhealthy`` / + # ``no_coverage``) plus the synthetic ``disabled`` we emit for + # switched-off routes. Any unknown state sorts after the known set. + preferred_order = [ + "healthy", + "unhealthy", + "no_coverage", + "disabled", + ] + by_state: list[BreakdownBucket] = [] + for label in preferred_order: + count = state_counts.pop(label, 0) + if count: + by_state.append(BreakdownBucket(label=label, count=count)) + for label, count in sorted(state_counts.items()): + by_state.append(BreakdownBucket(label=label, count=count)) + + return RoutesOverview(days=days, by_state=by_state, routes=entries) diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index 0df70c0..cad1d0f 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -244,7 +244,7 @@ def create_route( @router.get("/{route_id}", response_model=RouteDetail) @cached( "routes/{id}", - ttl_setting="redis_cache_ttl_route_detail", + ttl_setting="redis_cache_ttl_dashboard", key_builder=_routes_key_builder, ) def get_route( diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py index 6761c8c..af70ab6 100644 --- a/src/meshcore_hub/collector/routes.py +++ b/src/meshcore_hub/collector/routes.py @@ -585,13 +585,20 @@ def evaluate_route_history( """Evaluate a route over *days* calendar-day buckets (oldest first). Returns a list of ``(date, quality, state, matched_count)`` tuples. - When *include_today* is True, an extra partial-day entry for today is - appended. For a disabled route, every day returns ``unknown`` / - ``no_coverage`` / ``0`` without hitting the DB. + When *include_today* is True, an extra entry is appended whose + ``quality``/``state``/``matched_count`` are computed via + :func:`evaluate_route` over the route's rolling ``window_hours`` window + (the same call used to populate ``route_result`` for the badge), so the + rightmost chart segment matches the card's badge. The ``date`` of that + entry is today's date — its underlying window may extend into yesterday + when ``window_hours`` > time elapsed since UTC midnight. Historical + segments remain UTC calendar-day buckets. For a disabled route, every + day returns ``unknown`` / ``no_coverage`` / ``0`` without hitting the DB. Performance: fetches only hops matching the route's expected prefixes (one DB query) plus one GROUP BY existence check, then partitions by - day in Python. + day in Python. When *include_today* is True, an additional bounded + ``evaluate_route`` call is made for the rolling-window segment. """ current = now or datetime.now(timezone.utc) today_midnight = current.replace(hour=0, minute=0, second=0, microsecond=0) @@ -667,7 +674,16 @@ def evaluate_route_history( last_prefix = expected[-1] last_prefix_end = _hex_prefix_end(last_prefix) - day_paths: list[dict[str, list[dict[str, Any]]]] = [{} for _ in range(total_days)] + # When include_today is set, the rightmost segment is evaluated via + # ``evaluate_route`` over the route's rolling ``window_hours`` window + # so that it matches ``route_result.quality`` (the badge). Historical + # segments remain UTC calendar-day buckets. We therefore partition + # packets only into the historical indices and append today separately. + historical_days = total_days - (1 if include_today else 0) + + day_paths: list[dict[str, list[dict[str, Any]]]] = [ + {} for _ in range(historical_days) + ] for rp_id, hops in all_paths.items(): for hop in hops: h = hop["node_hash"] @@ -679,18 +695,18 @@ def evaluate_route_history( or (last_prefix is not None and last_prefix <= h < last_prefix_end) ): continue - for i in range(total_days): + for i in range(historical_days): if day_starts[i] <= ra < day_ends[i]: if rp_id not in day_paths[i]: day_paths[i][rp_id] = hops break - # --- Evaluate each day (CPU-only, no DB) --- + # --- Evaluate each historical day (CPU-only, no DB) --- eff_clear = effective_clear_threshold(route) threshold = route.packet_count_threshold results: list[tuple[date, str, str, int]] = [] - for i in range(total_days): + for i in range(historical_days): matched_packets: set[str] = set() for hops in day_paths[i].values(): if _match_hops(hops, expected, route.max_hop_span, reversible): @@ -711,6 +727,14 @@ def evaluate_route_history( quality = derive_quality(state, matched_count, threshold, eff_clear) results.append((day_dates[i], quality, state, matched_count)) + # --- Today segment: rolling window, same call as the badge evaluator --- + if include_today: + rolling_start = current - timedelta(hours=route.window_hours) + today_state, today_quality, today_matched = evaluate_route( + session, route, rolling_start + ) + results.append((day_dates[-1], today_quality, today_state, today_matched)) + return results diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 0f2e93b..ef7e6d3 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -418,16 +418,15 @@ class APISettings(CommonSettings): description="Default cache TTL in seconds", ) redis_cache_ttl_dashboard: int = Field( - default=300, + default=3600, description=( - "Cache TTL in seconds for dashboard endpoints and per-route " - "health history (trend/aggregation data tolerates longer staleness)" + "Cache TTL in seconds for dashboard endpoints, /routes/{id} detail " + "and per-route health history (trend/aggregation data tolerates " + "longer staleness than the default TTL). The Recent Adverts / " + "Recent Channel Messages widgets live on a separate " + "/dashboard/recent-activity endpoint cached at redis_cache_ttl." ), ) - redis_cache_ttl_route_detail: int = Field( - default=300, - description="Cache TTL for /routes/{id} detail endpoint (seconds)", - ) # HTTP Cache-Control headers api_cache_control_enabled: bool = Field( diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index 4842150..fadeab4 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -267,7 +267,12 @@ class ChannelMessage(BaseModel): class DashboardStats(BaseModel): - """Schema for dashboard statistics.""" + """Schema for dashboard aggregate statistics (counts only). + + The Recent Adverts / Recent Channel Messages lists used to live here + but were split into ``GET /dashboard/recent-activity`` so they can + carry a shorter cache TTL than the expensive aggregate counts. + """ total_nodes: int = Field(..., description="Total number of nodes") active_nodes: int = Field(..., description="Nodes active in last 24h") @@ -281,17 +286,10 @@ class DashboardStats(BaseModel): advertisements_7d: int = Field( default=0, description="Advertisements received in last 7 days" ) - recent_advertisements: list[RecentAdvertisement] = Field( - default_factory=list, description="Last 10 advertisements" - ) channel_message_counts: dict[int, int] = Field( default_factory=dict, description="Message count per channel", ) - channel_messages: dict[int, list[ChannelMessage]] = Field( - default_factory=dict, - description="Recent messages per channel (up to 5 each)", - ) total_operators: int = Field(default=0, description="Number of operator-role users") total_members: int = Field( default=0, description="Number of member-role users (excluding operators)" @@ -300,6 +298,25 @@ class DashboardStats(BaseModel): packets_7d: int = Field(default=0, description="Packets captured in last 7 days") +class RecentActivity(BaseModel): + """Schema for ``GET /dashboard/recent-activity``. + + Hosts the recent-adverts / recent-channel-messages lists that used to + be embedded in ``DashboardStats``. Cached at the default ``redis_cache_ttl`` + (30 s) so the dashboard's Recent panels stay fresh even while the + aggregate counts are cached at the longer ``redis_cache_ttl_dashboard``. + Channel visibility is role-scoped via the cache key builder. + """ + + recent_advertisements: list[RecentAdvertisement] = Field( + default_factory=list, description="Last 10 advertisements" + ) + channel_messages: dict[int, list[ChannelMessage]] = Field( + default_factory=dict, + description="Recent messages per channel (up to 5 each, role-visible only)", + ) + + class DailyActivityPoint(BaseModel): """Schema for a single day's activity count.""" diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py index 242affc..8864311 100644 --- a/src/meshcore_hub/common/schemas/routes.py +++ b/src/meshcore_hub/common/schemas/routes.py @@ -260,3 +260,38 @@ class RouteHistory(BaseModel): route_id: str days: int data: list[RouteDayQuality] + + +class RouteOverviewEntry(BaseModel): + """One route's current state plus its recent history (dashboard widget).""" + + id: str + from_label: str + to_label: str + visibility: str + enabled: bool + state: Optional[str] = None + quality: Optional[str] = None + matched_count: Optional[int] = None + history: list[RouteDayQuality] = [] + + +class RoutesOverview(BaseModel): + """Aggregate route-fleet snapshot for the dashboard. + + - ``by_state`` buckets routes by their current ``state`` (clear / + marginal / failing / no_coverage / disabled) so a single stacked + bar can render fleet health. + - ``routes`` carries per-route 7-day history for the trend line and + the per-route strip grid. The frontend slices the top N by + ``matched_count`` for display. + """ + + days: int + by_state: list["BreakdownBucket"] + routes: list[RouteOverviewEntry] + + +from meshcore_hub.common.schemas.messages import BreakdownBucket # noqa: E402 + +RoutesOverview.model_rebuild() diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index 7cef40b..afc28dd 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -86,6 +86,8 @@ .nav-icon-dashboard { color: var(--color-dashboard); } .nav-icon-nodes { color: var(--color-nodes); } .nav-icon-adverts { color: var(--color-adverts); } +.nav-icon-channels { color: var(--color-channels); } +.nav-icon-routes { color: var(--color-routes); } .nav-icon-messages { color: var(--color-messages); } .nav-icon-packets { color: var(--color-packets); } .nav-icon-map { color: var(--color-map); } @@ -95,6 +97,8 @@ .navbar .menu li:has(.nav-icon-dashboard) { --nav-color: var(--color-dashboard); } .navbar .menu li:has(.nav-icon-nodes) { --nav-color: var(--color-nodes); } .navbar .menu li:has(.nav-icon-adverts) { --nav-color: var(--color-adverts); } +.navbar .menu li:has(.nav-icon-channels) { --nav-color: var(--color-channels); } +.navbar .menu li:has(.nav-icon-routes) { --nav-color: var(--color-routes); } .navbar .menu li:has(.nav-icon-messages) { --nav-color: var(--color-messages); } .navbar .menu li:has(.nav-icon-packets) { --nav-color: var(--color-packets); } .navbar .menu li:has(.nav-icon-map) { --nav-color: var(--color-map); } @@ -125,6 +129,16 @@ border-left: 5px solid color-mix(in oklch, var(--panel-color, transparent) 55%, transparent); } +/* Route-health strip grid (dashboard widget). + Each cell is a fixed-width colored square; the row flexes so long + route labels truncate while the strip stays compact. */ +.route-health-cell { + width: 14px; + height: 14px; + border-radius: 2px; + display: inline-block; +} + .radio-tile-icon { color: var(--color-radio); } diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js index 71613c9..c0c1fb6 100644 --- a/src/meshcore_hub/web/static/js/charts.js +++ b/src/meshcore_hub/web/static/js/charts.js @@ -42,6 +42,8 @@ const ChartColors = { get messagesFill() { return withAlpha(this.messages, 0.1); }, get packets() { return getCSSColor('--color-packets', 'oklch(0.72 0.17 145)'); }, get packetsFill() { return withAlpha(this.packets, 0.1); }, + get routes() { return getCSSColor('--color-routes', 'oklch(0.72 0.17 30)'); }, + get routesFill() { return withAlpha(this.routes, 0.1); }, // Neutral grays (not page-specific) grid: 'oklch(0.4 0 0 / 0.2)', @@ -322,9 +324,142 @@ function createStackedBarChart(canvasId, buckets, colors) { }); } +/** + * Create a multi-line route-status trend chart for the dashboard. + * + * Each route becomes one line plotted on a 3-tier categorical Y axis + * (``failing`` → ``marginal`` → ``clear``, bottom to top). The line's + * color reflects the route's CURRENT quality (its latest evaluation), + * so multiple routes in the same health band share a color — the chart + * reads as a fleet-health overview rather than per-route identity. + * Hover tooltips still show the route label, tier, and matched_count. + * + * Input is the ``routes`` array from ``GET /dashboard/routes-overview``. + * The top ``maxRoutes`` routes by current ``matched_count`` are drawn; + * the rest are dropped silently (summing quality tiers is meaningless). + * + * Quality → tier mapping (per the merged-3-tier design): + * ``clear`` → clear + * ``marginal`` → marginal + * anything else → failing (covers ``failing``, ``unknown``, + * ``no_coverage``, ``disabled``, null) + * + * @param {string} canvasId - ID of the canvas element + * @param {Array|null} routes - Array of RouteOverviewEntry objects + * @param {number} [maxRoutes=6] - Top-N routes drawn distinctly + * @returns {Chart|null} + */ +function createRoutesTrendChart(canvasId, routes, maxRoutes) { + var ctx = document.getElementById(canvasId); + if (!ctx || !routes || routes.length === 0) return null; + + maxRoutes = maxRoutes || 6; + + // 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) { + var am = a.matched_count || 0; + var bm = b.matched_count || 0; + return bm - am; + }); + var top = sorted.slice(0, maxRoutes); + + // Use the longest history as the X-axis label source (all routes + // share the same window in practice, but be defensive). + var labels = []; + for (var i = 0; i < top.length; i++) { + if (top[i].history && top[i].history.length > labels.length) { + labels = formatDateLabels(top[i].history); + } + } + if (labels.length === 0) return null; + + var datasets = top.map(function(entry) { + var history = entry.history || []; + var avgTier = averageTier(history); + return { + label: entry.from_label + ' \u2192 ' + entry.to_label, + data: history.map(function(d) { return qualityToTier(d.quality); }), + borderColor: tierColor(avgTier), + backgroundColor: 'transparent', + fill: false, + tension: 0.3, + cubicInterpolationMode: 'monotone', + pointRadius: 2, + pointHoverRadius: 5, + spanGaps: true, + _matched: history.map(function(d) { return d.matched_count || 0; }) + }; + }); + + var opts = createChartOptions(false); + // Replace the default numeric Y axis with a 3-tier categorical axis. + opts.scales.y = { + type: 'category', + labels: tierOrder, + reverse: true, + grid: { color: ChartColors.grid }, + ticks: { + color: ChartColors.text, + callback: function(_value, index) { + var tier = tierOrder[index]; + return (window.t && window.t('routes.quality_' + tier)) || tier; + } + } + }; + // The default tooltip formatter calls formatNumber(ctx.parsed.y), + // which is wrong for categorical string values; emit tier + matched. + opts.plugins.tooltip.callbacks = { + title: function(items) { return items[0].label; }, + label: function(ctx) { + var tier = tierOrder[ctx.parsed.y] || 'failing'; + var tierLabel = (window.t && window.t('routes.quality_' + tier)) || tier; + var matched = (ctx.dataset._matched && ctx.dataset._matched[ctx.dataIndex]) || 0; + return ctx.dataset.label + ': ' + tierLabel + ' (' + matched + ')'; + } + }; + + return new Chart(ctx, { + type: 'line', + data: { labels: labels, datasets: datasets }, + options: opts + }); +} + /** * Initialize dashboard charts (nodes, advertisements, messages, packets, - * plus optional packet-breakdown stacked bars). + * plus optional packet-breakdown stacked bars and routes overview). * Pass null for any data parameter to skip that chart. * @param {Object|null} nodeData - Node count data, or null to skip * @param {Object|null} advertData - Advertisement data, or null to skip @@ -332,8 +467,9 @@ function createStackedBarChart(canvasId, buckets, colors) { * @param {Object|null} packetData - Raw-packet trend data, or null to skip * @param {Array|null} [eventTypeData] - Packet event-type breakdown buckets * @param {Array|null} [pathWidthData] - Packet path-width breakdown buckets + * @param {Array|null} [routesData] - Routes overview ``routes`` array */ -function initDashboardCharts(nodeData, advertData, messageData, packetData, eventTypeData, pathWidthData) { +function initDashboardCharts(nodeData, advertData, messageData, packetData, eventTypeData, pathWidthData, routesData) { if (nodeData) { createLineChart( 'nodeChart', @@ -393,6 +529,10 @@ function initDashboardCharts(nodeData, advertData, messageData, packetData, even ChartColors.breakdown.slice(0, 3) ); } + + if (routesData && routesData.length > 0) { + createRoutesTrendChart('routesTrendChart', routesData); + } } /** diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index f0f1575..d617122 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -231,11 +231,11 @@ function renderMobileNav(config) { if (features.advertisements !== false) { items.push(html`
  • ${iconAdvertisements('h-5 w-5 nav-icon-adverts')} ${t('entities.advertisements')}
  • `); } - if (features.channels !== false) { - items.push(html`
  • ${iconChannel('h-5 w-5')} ${t('entities.channels')}
  • `); - } if (features.routes !== false) { - items.push(html`
  • ${iconPath('h-5 w-5')} ${t('entities.routes')}
  • `); + items.push(html`
  • ${iconPath('h-5 w-5 nav-icon-routes')} ${t('entities.routes')}
  • `); + } + if (features.channels !== false) { + items.push(html`
  • ${iconChannel('h-5 w-5 nav-icon-channels')} ${t('entities.channels')}
  • `); } if (features.messages !== false) { items.push(html`
  • ${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}
  • `); diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index c2dcc31..1232bd9 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -659,10 +659,12 @@ export function observerFilterBadges({ areas, disabled, onToggle, extraClass = ' const title = enabled ? t('common.filter_observer_disable') : t('common.filter_observer_enable'); + const emoji = extractFirstEmoji(n._displayName); + const label = emoji ? (n._displayName.replace(emoji, '').trim() || n._displayName) : n._displayName; return html``; + @click=${() => onToggle(n.public_key)}>${emoji ? html`${emoji}` : nothing}${label}`; })} `; } 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 7bc3608..b884e45 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -119,12 +119,62 @@ function gridCols(count) { return ''; } -function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, stats, packetBreakdown }) { +function renderRoutesHealth(routes) { + if (!routes || routes.length === 0) { + return html`

    ${t('dashboard.routes_empty')}

    `; + } + // Top 6 by current matched_count, mirroring the trend chart cap so the + // two widgets surface the same routes. + const sorted = routes.slice().sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0)); + const maxRows = 6; + const visible = sorted.slice(0, maxRows); + const hidden = sorted.length - visible.length; + + const colorFor = (q) => { + // Mirrors ChartColors.quality in charts.js but reads CSS vars so the + // strips stay in sync with the legend. + const map = { + clear: 'oklch(0.72 0.17 145)', + marginal: 'oklch(0.75 0.18 85)', + failing: 'oklch(0.62 0.24 25)', + no_coverage: 'oklch(0.65 0.15 250)', + disabled: 'oklch(0.55 0 0)', + }; + return map[q] || map.no_coverage; + }; + const labelFor = (q) => t('routes.quality_' + (q || 'unknown')); + + const rows = visible.map(r => { + const cells = (r.history || []).map(d => html` +
    `); + const current = r.quality || (r.enabled ? 'no_coverage' : 'disabled'); + return html`
    + + ${r.from_label} \u2192 ${r.to_label} + +
    ${cells}
    + +
    `; + }); + + return html`
    + ${rows} + ${hidden > 0 ? html`

    ${t('dashboard.routes_more', { count: hidden })}

    ` : nothing} +
    `; +} + +function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, showRoutes, stats, packetBreakdown, routesOverview }) { const visibleCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0) + (showPackets ? 1 : 0); if (visibleCount === 0) return nothing; const eventTypeTotal = packetBreakdown?.by_event_type?.reduce((s, b) => s + b.count, 0) ?? 0; const pathWidthTotal = packetBreakdown?.by_path_width?.reduce((s, b) => s + b.count, 0) ?? 0; + const hasRoutes = !!(routesOverview && routesOverview.routes && routesOverview.routes.length); return html`
    @@ -213,8 +263,9 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s
    ` : nothing} -${showPackets ? html` -
    +${(showPackets || (showRoutes && hasRoutes)) ? html` +
    + ${showPackets ? html`
    @@ -233,8 +284,9 @@ ${showPackets ? html`
    -
    +
    ` : nothing} + ${showPackets ? html`
    @@ -253,7 +305,39 @@ ${showPackets ? html`
    -
    +
    ` : nothing} + + ${(showRoutes && hasRoutes) ? html` +
    +
    +
    +
    +

    + ${t('dashboard.route_health')} +

    +

    ${t('time.last_7_days')}

    +
    +
    + ${renderRoutesHealth(routesOverview.routes)} +
    +
    ` : nothing} + + ${(showRoutes && hasRoutes) ? html` +
    +
    +
    +
    +

    + ${t('dashboard.routes_trend')} +

    +

    ${t('time.routes_over_last_n_days', { n: routesOverview.days })}

    +
    +
    +
    + +
    +
    +
    ` : nothing} ` : nothing}`; } @@ -267,14 +351,17 @@ export async function render(container, params, router) { const showAdverts = features.advertisements !== false; const showMessages = features.messages !== false; const showPackets = features.packets !== false; + const showRoutes = features.routes !== false; - const [stats, advertActivity, messageActivity, nodeCount, packetActivity, packetBreakdown, channelsData] = await Promise.all([ + const [stats, recentActivity, advertActivity, messageActivity, nodeCount, packetActivity, packetBreakdown, routesOverview, channelsData] = await Promise.all([ apiGet('/api/v1/dashboard/stats', {}, { signal }), + apiGet('/api/v1/dashboard/recent-activity', {}, { signal }), apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }), apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }), apiGet('/api/v1/dashboard/node-count', { days: 7 }, { signal }), apiGet('/api/v1/dashboard/packet-activity', { days: 7 }, { signal }), apiGet('/api/v1/dashboard/packet-breakdown', { days: 7 }, { signal }), + showRoutes ? apiGet('/api/v1/dashboard/routes-overview', { days: 7 }, { signal }) : Promise.resolve(null), apiGet('/api/v1/channels', {}, { signal }), ]); channelLabels = new Map([ @@ -294,7 +381,7 @@ export async function render(container, params, router) { ${(showNodes || showAdverts || showMessages || showPackets) ? html` -${renderChartCards({ showNodes, showAdverts, showMessages, showPackets, stats, packetBreakdown })}` : nothing} +${renderChartCards({ showNodes, showAdverts, showMessages, showPackets, showRoutes, stats, packetBreakdown, routesOverview })}` : nothing} ${bottomCount > 0 ? html`
    @@ -305,11 +392,11 @@ ${bottomCount > 0 ? html` ${iconAdvertisements('h-6 w-6')} ${t('common.recent_entity', { entity: t('entities.advertisements') })} - ${renderRecentAds(stats.recent_advertisements)} + ${renderRecentAds(recentActivity.recent_advertisements)}
    ` : nothing} - ${showMessages ? renderChannelMessages(stats.channel_messages, channelLabels) : nothing} + ${showMessages ? renderChannelMessages(recentActivity.channel_messages, channelLabels) : nothing} ` : nothing}`, container); window.initDashboardCharts( @@ -319,9 +406,10 @@ ${bottomCount > 0 ? html` showPackets ? packetActivity : null, showPackets ? packetBreakdown.by_event_type : null, showPackets ? packetBreakdown.by_path_width : null, + (showRoutes && routesOverview && routesOverview.routes) ? routesOverview.routes : null, ); - const chartIds = ['nodeChart', 'advertChart', 'messageChart', 'packetChart', 'packetEventTypeChart', 'packetPathWidthChart']; + const chartIds = ['nodeChart', 'advertChart', 'messageChart', 'packetChart', 'packetEventTypeChart', 'packetPathWidthChart', 'routesTrendChart']; return () => { chartIds.forEach(id => { const canvas = document.getElementById(id); diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index d5ef873..17cdfc1 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -96,18 +96,18 @@ function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity, label: t('entities.advertisements'), colorVar: '--color-adverts', }) : nothing} - ${features.channels !== false ? renderNavCard({ - href: '/channels', - icon: iconChannel('w-full h-full'), - label: t('entities.channels'), - colorVar: '--color-channels', - }) : nothing} ${features.routes !== false ? renderNavCard({ href: '/routes', icon: iconPath('w-full h-full'), label: t('entities.routes'), colorVar: '--color-routes', }) : nothing} + ${features.channels !== false ? renderNavCard({ + href: '/channels', + icon: iconChannel('w-full h-full'), + label: t('entities.channels'), + colorVar: '--color-channels', + }) : nothing} ${features.messages !== false ? renderNavCard({ href: '/messages', icon: iconMessages('w-full h-full'), 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 3f2084e..e02b5b2 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -121,7 +121,6 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, p const label = qualityLabel(q, route.enabled); const dot = qualityDot(q, route.enabled); const tip = diagnosisText(route); - const arrow = route.reversible !== false ? '\u2194' : '\u2192'; const badge = tip ? html`${dot} ${label}` : html`${dot} ${label}`; @@ -158,7 +157,6 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, p ${route.description ? html`

    ${route.description}

    ` : nothing}
    - ${arrow} ${badge}
    @@ -181,7 +179,7 @@ function renderDetailContent(route, detail, { navigate, packetsEnabled, history ${history.data && history.data.length > 0 ? html`
    - ${history.data.map(d => html`${new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}`)} + ${history.data.map((d, i) => html`${i === history.data.length - 1 ? t('routes.last_n_hours', { n: route.window_hours }) : new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}`)}
    ` : nothing} ` : nothing; diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index e42b015..9b53646 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -138,7 +138,8 @@ "last_7_days": "Last 7 days", "per_day_last_7_days": "Per day (last 7 days)", "over_time_last_7_days": "Over time (last 7 days)", - "activity_per_day_last_7_days": "Activity per day (last 7 days)" + "activity_per_day_last_7_days": "Activity per day (last 7 days)", + "routes_over_last_n_days": "Matched packets per route (last {{n}} days)" }, "node_types": { "chat": "Chat", @@ -161,7 +162,11 @@ "dashboard": { "all_discovered_nodes": "All discovered nodes", "recent_channel_messages": "Recent Channel Messages", - "channel": "Channel {{number}}" + "channel": "Channel {{number}}", + "routes_trend": "Route Trends", + "route_health": "Route Health", + "routes_more": "+{{count}} more routes", + "routes_empty": "No routes configured yet." }, "nodes": { "scan_to_add": "Scan to add as contact", @@ -346,7 +351,9 @@ "quality_no_coverage": "No Coverage", "quality_unknown": "Unknown", "recent_packets": "Recent Packets", - "min_nodes_error": "At least 2 path nodes are required." + "last_n_hours": "Last {{n}}h", + "min_nodes_error": "At least 2 path nodes are required.", + "other_routes": "Other routes" }, "not_found": { "description": "The page you're looking for doesn't exist or has been moved." diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index 9bbdcdd..f7622be 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -128,7 +128,8 @@ "last_7_days": "Laatste 7 dagen", "per_day_last_7_days": "Per dag (laatste 7 dagen)", "over_time_last_7_days": "In de tijd (laatste 7 dagen)", - "activity_per_day_last_7_days": "Activiteit per dag (laatste 7 dagen)" + "activity_per_day_last_7_days": "Activiteit per dag (laatste 7 dagen)", + "routes_over_last_n_days": "Overeenkomende pakketten per route (laatste {{n}} dagen)" }, "node_types": { "chat": "Chat", @@ -150,7 +151,11 @@ "dashboard": { "all_discovered_nodes": "Alle ontdekte knooppunten", "recent_channel_messages": "Recente kanaalberichten", - "channel": "Kanaal {{number}}" + "channel": "Kanaal {{number}}", + "routes_trend": "Route-trends", + "route_health": "Route-status", + "routes_more": "+{{count}} routes meer", + "routes_empty": "Nog geen routes geconfigureerd." }, "nodes": { "scan_to_add": "Scan om als contact toe te voegen" @@ -268,7 +273,9 @@ "quality_no_coverage": "Geen dekking", "quality_unknown": "Onbekend", "recent_packets": "Recente packets", - "min_nodes_error": "Minimaal 2 padknooppunten zijn vereist." + "last_n_hours": "Laatste {{n}}u", + "min_nodes_error": "Minimaal 2 padknooppunten zijn vereist.", + "other_routes": "Overige routes" }, "not_found": { "description": "De pagina die u zoekt bestaat niet of is verplaatst." diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 8af3ba6..2cb5655 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -70,12 +70,12 @@ {% if features.advertisements %}
  • {{ t('entities.advertisements') }}
  • {% endif %} - {% if features.channels %} -
  • {{ t('entities.channels') }}
  • - {% endif %} {% if features.routes %}
  • {{ t('entities.routes') }}
  • {% endif %} + {% if features.channels %} +
  • {{ t('entities.channels') }}
  • + {% endif %} {% if features.messages %}
  • {{ t('entities.messages') }}
  • {% endif %} diff --git a/tests/test_api/test_cache.py b/tests/test_api/test_cache.py index 99a94d9..9393675 100644 --- a/tests/test_api/test_cache.py +++ b/tests/test_api/test_cache.py @@ -436,9 +436,9 @@ class TestCachedDecorator: mock_cache.get.return_value = None app.state.redis_cache = mock_cache app.state.redis_cache_ttl = 30 - app.state.redis_cache_ttl_route_detail = 90 + app.state.redis_cache_ttl_dashboard = 90 - @cached("routes/{id}", ttl_setting="redis_cache_ttl_route_detail") + @cached("routes/{id}", ttl_setting="redis_cache_ttl_dashboard") async def handler(request: Request): return {"id": "abc", "matches": []} @@ -807,9 +807,9 @@ class TestCachedEtag: app = FastAPI() app.state.redis_cache = NullCache() app.state.redis_cache_ttl = 30 - app.state.redis_cache_ttl_route_detail = 90 + app.state.redis_cache_ttl_dashboard = 90 - @cached("routes/{id}", ttl_setting="redis_cache_ttl_route_detail") + @cached("routes/{id}", ttl_setting="redis_cache_ttl_dashboard") async def handler(request: Request): return {"id": "abc"} @@ -898,7 +898,7 @@ class TestCacheControlMiddleware: mock_cache.get.return_value = None client_no_auth.app.state.redis_cache = mock_cache client_no_auth.app.state.redis_cache_ttl = 30 - client_no_auth.app.state.redis_cache_ttl_route_detail = 300 + client_no_auth.app.state.redis_cache_ttl_dashboard = 300 # Hit the route detail endpoint with a fake id. The endpoint returns # 404 but still flows through the @cached decorator; we only care # that the TTL is NOT surfaced as an HTTP header. @@ -1176,8 +1176,6 @@ class TestCliRedis: "60", "--redis-cache-ttl-dashboard", "120", - "--redis-cache-ttl-route-detail", - "90", "--no-api-cache-control", ], catch_exceptions=False, @@ -1191,7 +1189,6 @@ class TestCliRedis: assert call_kwargs["redis_key_prefix"] == "pre" assert call_kwargs["redis_cache_ttl"] == 60 assert call_kwargs["redis_cache_ttl_dashboard"] == 120 - assert call_kwargs["redis_cache_ttl_route_detail"] == 90 assert call_kwargs["api_cache_control_enabled"] is False def test_api_cache_control_enabled_default(self): @@ -1214,10 +1211,13 @@ class TestCliRedis: assert call_kwargs["api_cache_control_enabled"] is True def test_redis_cache_ttl_dashboard_default(self): - """--redis-cache-ttl-dashboard defaults to 300 (raised from 30). + """--redis-cache-ttl-dashboard defaults to 3600 (1 hour). - Covers /dashboard/* endpoints and /routes/{id}/history. Trend data - tolerates longer staleness than the original 30s default. + Covers /dashboard/* endpoints, /routes/{id} detail, and + /routes/{id}/history. Trend/aggregation data tolerates much longer + staleness than the default 30 s TTL — Recent Adverts / Recent + Channel Messages widgets were split into /dashboard/recent-activity + so they can stay on the short default TTL independently. """ from click.testing import CliRunner @@ -1234,7 +1234,7 @@ class TestCliRedis: mock_create_app.return_value = MagicMock() runner.invoke(api, catch_exceptions=False) call_kwargs = mock_create_app.call_args[1] - assert call_kwargs["redis_cache_ttl_dashboard"] == 300 + assert call_kwargs["redis_cache_ttl_dashboard"] == 3600 class TestKeyBuilders: @@ -1363,8 +1363,12 @@ class TestCacheInvalidationHelpers: cache = MagicMock() invalidate_routes(_make_request_with_cache(cache)) - # Single prefix covers /routes, /routes/{id}, /routes/{id}/history - cache.delete.assert_called_once_with("/api/v1/routes") + # Two prefixes: /api/v1/routes covers the list, detail, and history + # endpoints (URL-path key_builder); dashboard/routes-overview covers + # the dashboard aggregate (endpoint-name key namespace). + assert cache.delete.call_count == 2 + cache.delete.assert_any_call("/api/v1/routes") + cache.delete.assert_any_call("dashboard/routes-overview") def test_invalidate_nodes_drops_endpoint_name_prefix(self): from meshcore_hub.api.cache_invalidation import invalidate_nodes @@ -1438,7 +1442,6 @@ class TestMutationInvalidationIntegration: client.app.state.redis_cache = mock_cache client.app.state.redis_cache_ttl = 30 client.app.state.redis_cache_ttl_dashboard = 300 - client.app.state.redis_cache_ttl_route_detail = 300 return mock_cache # --- Channels --------------------------------------------------------- @@ -1495,6 +1498,10 @@ class TestMutationInvalidationIntegration: ) assert resp.status_code == 201 mock_cache.delete.assert_any_call("/api/v1/routes") + # Dashboard routes-overview embeds per-route state, so it must be + # dropped too — otherwise the new route stays invisible on the + # dashboard until TTL expiry. + mock_cache.delete.assert_any_call("dashboard/routes-overview") def test_update_route_invalidates_routes(self, client_no_auth, api_db_session): from meshcore_hub.common.models import Node, Route, RouteNode @@ -1524,6 +1531,7 @@ class TestMutationInvalidationIntegration: ) assert resp.status_code == 200 mock_cache.delete.assert_any_call("/api/v1/routes") + mock_cache.delete.assert_any_call("dashboard/routes-overview") def test_delete_route_invalidates_routes(self, client_no_auth, api_db_session): from meshcore_hub.common.models import Node, Route, RouteNode @@ -1552,6 +1560,7 @@ class TestMutationInvalidationIntegration: ) assert resp.status_code == 204 mock_cache.delete.assert_any_call("/api/v1/routes") + mock_cache.delete.assert_any_call("dashboard/routes-overview") # --- User profiles ---------------------------------------------------- diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index 3f261c5..77e3554 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -16,6 +16,11 @@ from meshcore_hub.common.models import ( RawPacket, ) from meshcore_hub.common.models import UserProfile +from meshcore_hub.common.models import ( + PacketPathHop, + Route, + RouteNode, +) class TestDateBucketKey: @@ -59,6 +64,9 @@ class TestDashboardStats: assert data["total_packets"] == 0 assert data["packets_7d"] == 0 assert data["channel_message_counts"] == {} + # recent_advertisements / channel_messages moved to /recent-activity + assert "recent_advertisements" not in data + assert "channel_messages" not in data def test_get_stats_with_data( self, client_no_auth, sample_node, sample_message, sample_advertisement @@ -838,7 +846,7 @@ class TestDashboardFloodOnlyFilter: api_db_session.add_all([direct_ad, flood_ad]) api_db_session.commit() - response = client_no_auth.get("/api/v1/dashboard/stats") + response = client_no_auth.get("/api/v1/dashboard/recent-activity") assert response.status_code == 200 data = response.json() assert len(data["recent_advertisements"]) == 1 @@ -874,56 +882,62 @@ class TestDashboardFloodOnlyFilter: assert total_count == 1 +@pytest.fixture +def channels_with_messages(api_db_session): + """Create public and admin channels with messages. + + Module-level so both TestDashboardChannelVisibility (channel_message_counts + on /dashboard/stats) and TestDashboardRecentActivity (channel_messages on + /dashboard/recent-activity) can share the same setup. + """ + pub_key = "AABBCCDDEEFF00112233445566778899" + adm_key = "FFEEDDCCBBAA99887766554433221100" + pub_idx = int(Channel.compute_channel_hash(pub_key), 16) + adm_idx = int(Channel.compute_channel_hash(adm_key), 16) + + pub_ch = Channel( + name="CommunityCh", + key_hex=pub_key, + channel_hash=Channel.compute_channel_hash(pub_key), + visibility="community", + enabled=True, + ) + adm_ch = Channel( + name="AdminCh", + key_hex=adm_key, + channel_hash=Channel.compute_channel_hash(adm_key), + visibility="admin", + enabled=True, + ) + api_db_session.add_all([pub_ch, adm_ch]) + + pub_msg = Message( + message_type="channel", + channel_idx=pub_idx, + text="Public message", + received_at=datetime.now(timezone.utc), + ) + adm_msg = Message( + message_type="channel", + channel_idx=adm_idx, + text="Admin message", + received_at=datetime.now(timezone.utc), + ) + direct_msg = Message( + message_type="direct", + pubkey_prefix="abc123", + text="Direct message", + received_at=datetime.now(timezone.utc), + ) + api_db_session.add_all([pub_msg, adm_msg, direct_msg]) + api_db_session.commit() + + return pub_idx, adm_idx + + class TestDashboardChannelVisibility: """Tests for channel visibility filtering on dashboard stats.""" - @pytest.fixture - def channels_with_messages(self, api_db_session): - """Create public and admin channels with messages.""" - pub_key = "AABBCCDDEEFF00112233445566778899" - adm_key = "FFEEDDCCBBAA99887766554433221100" - pub_idx = int(Channel.compute_channel_hash(pub_key), 16) - adm_idx = int(Channel.compute_channel_hash(adm_key), 16) - - pub_ch = Channel( - name="CommunityCh", - key_hex=pub_key, - channel_hash=Channel.compute_channel_hash(pub_key), - visibility="community", - enabled=True, - ) - adm_ch = Channel( - name="AdminCh", - key_hex=adm_key, - channel_hash=Channel.compute_channel_hash(adm_key), - visibility="admin", - enabled=True, - ) - api_db_session.add_all([pub_ch, adm_ch]) - - pub_msg = Message( - message_type="channel", - channel_idx=pub_idx, - text="Public message", - received_at=datetime.now(timezone.utc), - ) - adm_msg = Message( - message_type="channel", - channel_idx=adm_idx, - text="Admin message", - received_at=datetime.now(timezone.utc), - ) - direct_msg = Message( - message_type="direct", - pubkey_prefix="abc123", - text="Direct message", - received_at=datetime.now(timezone.utc), - ) - api_db_session.add_all([pub_msg, adm_msg, direct_msg]) - api_db_session.commit() - - return pub_idx, adm_idx - def test_anonymous_sees_only_community_messages( self, client_no_auth, channels_with_messages ): @@ -1038,7 +1052,7 @@ class TestDashboardChannelVisibility: api_db_session.add(ad) api_db_session.commit() - response = client_no_auth.get("/api/v1/dashboard/stats") + response = client_no_auth.get("/api/v1/dashboard/recent-activity") assert response.status_code == 200 data = response.json() assert len(data["recent_advertisements"]) == 1 @@ -1084,7 +1098,7 @@ class TestDashboardChannelVisibility: api_db_session.add(observer) api_db_session.commit() - response = client_no_auth.get("/api/v1/dashboard/stats") + response = client_no_auth.get("/api/v1/dashboard/recent-activity") assert response.status_code == 200 data = response.json() assert len(data["recent_advertisements"]) == 1 @@ -1118,7 +1132,7 @@ class TestDashboardChannelVisibility: api_db_session.add(ad) api_db_session.commit() - response = client_no_auth.get("/api/v1/dashboard/stats") + response = client_no_auth.get("/api/v1/dashboard/recent-activity") assert response.status_code == 200 data = response.json() assert len(data["recent_advertisements"]) == 1 @@ -1178,6 +1192,76 @@ class TestDashboardChannelVisibility: assert str(adm_idx) not in data["channel_message_counts"] +class TestDashboardRecentActivity: + """Tests for GET /dashboard/recent-activity (split from /dashboard/stats).""" + + def test_recent_activity_empty(self, client_no_auth): + """Recent activity endpoint with empty database returns empty shapes.""" + response = client_no_auth.get("/api/v1/dashboard/recent-activity") + assert response.status_code == 200 + data = response.json() + assert data["recent_advertisements"] == [] + assert data["channel_messages"] == {} + + def test_recent_activity_does_not_return_counts( + self, client_no_auth, channels_with_messages + ): + """The recent-activity endpoint drops channel_message_counts (those + stay on /dashboard/stats as aggregate counts).""" + response = client_no_auth.get("/api/v1/dashboard/recent-activity") + assert response.status_code == 200 + data = response.json() + assert "channel_message_counts" not in data + + def test_recent_activity_channel_messages_visibility( + self, client_no_auth, channels_with_messages + ): + """Anonymous viewers only see community-channel messages.""" + pub_idx, adm_idx = channels_with_messages + response = client_no_auth.get("/api/v1/dashboard/recent-activity") + assert response.status_code == 200 + data = response.json() + assert str(pub_idx) in data["channel_messages"] + assert str(adm_idx) not in data["channel_messages"] + + def test_recent_activity_admin_sees_all_channels( + self, client_no_auth, channels_with_messages + ): + """Admin role sees messages on every channel.""" + pub_idx, adm_idx = channels_with_messages + response = client_no_auth.get( + "/api/v1/dashboard/recent-activity", + headers={"X-User-Roles": "admin"}, + ) + assert response.status_code == 200 + data = response.json() + assert str(pub_idx) in data["channel_messages"] + assert str(adm_idx) in data["channel_messages"] + + def test_recent_activity_caches_at_default_ttl_not_dashboard( + self, client_no_auth, api_db_session + ): + """The recent-activity endpoint uses the default redis_cache_ttl + (not redis_cache_ttl_dashboard), so the Recent Adverts / Recent + Channel Messages widgets stay fresh at the 30 s default while the + aggregate counts on /dashboard/stats cache for an hour.""" + from unittest.mock import MagicMock + + client_no_auth.app.state.redis_cache = MagicMock() + client_no_auth.app.state.redis_cache.get.return_value = None + client_no_auth.app.state.redis_cache_ttl = 30 + client_no_auth.app.state.redis_cache_ttl_dashboard = 3600 + + client_no_auth.get("/api/v1/dashboard/recent-activity") + + # First MISS stores the response at the default TTL (30 s), not + # the dashboard TTL (3600 s) — that's the whole point of the split. + # cache.set is invoked positionally as set(key, envelope, ttl). + client_no_auth.app.state.redis_cache.set.assert_called_once() + args, _ = client_no_auth.app.state.redis_cache.set.call_args + assert args[2] == 30 + + class TestDashboardDateBucketRegression: """Regression tests for the Postgres date-bucket flatline bug. @@ -1281,3 +1365,278 @@ class TestDashboardDateBucketRegression: assert data["data"][seeded_idx]["count"] == 1 if seeded_idx > 0: assert data["data"][seeded_idx - 1]["count"] == 0 + + +# --------------------------------------------------------------------------- +# GET /dashboard/routes-overview +# --------------------------------------------------------------------------- + + +def _make_node_with_pk(session, public_key: str, name: str | None = None) -> Node: + node = Node(public_key=public_key, name=name) + session.add(node) + session.flush() + return node + + +def _make_route_with_nodes( + session, + from_label: str, + to_label: str, + pubkeys: list[str], + visibility: str = "community", + enabled: bool = True, + threshold: int = 3, + clear_bar: int | None = None, + match_width: int = 1, +) -> Route: + nodes = [_make_node_with_pk(session, pk) for pk in pubkeys] + route = Route( + from_label=from_label, + to_label=to_label, + visibility=visibility, + enabled=enabled, + match_width=match_width, + packet_count_threshold=threshold, + clear_threshold=clear_bar, + ) + 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 * match_width].upper(), + ) + ) + session.flush() + return route + + +def _make_reception_for_route( + session, + packet_hash: str, + path_hashes: list[str], + received_at: datetime, +) -> None: + """Insert a RawPacket + PacketPathHop rows for a test reception.""" + from uuid import uuid4 + + rp_id = str(uuid4()) + session.add( + RawPacket( + id=rp_id, + packet_hash=packet_hash, + received_at=received_at, + ) + ) + session.flush() + for pos, nh in enumerate(path_hashes): + session.add( + PacketPathHop( + raw_packet_id=rp_id, + position=pos, + node_hash=nh, + packet_hash=packet_hash, + received_at=received_at, + ) + ) + session.flush() + + +class TestRoutesOverview: + """Tests for GET /dashboard/routes-overview.""" + + def test_empty(self, client_no_auth): + """No routes seeded → empty routes array and by_state.""" + resp = client_no_auth.get("/api/v1/dashboard/routes-overview") + assert resp.status_code == 200 + data = resp.json() + assert data["routes"] == [] + assert data["by_state"] == [] + assert data["days"] == 7 + + def test_default_days_is_7(self, client_no_auth, api_db_session): + resp = client_no_auth.get("/api/v1/dashboard/routes-overview") + assert resp.json()["days"] == 7 + + def test_custom_days(self, client_no_auth, api_db_session): + resp = client_no_auth.get("/api/v1/dashboard/routes-overview?days=3") + assert resp.json()["days"] == 3 + + def test_days_clamped_to_retention(self, client_no_auth, api_db_session): + from meshcore_hub.common.config import get_collector_settings + + retention = get_collector_settings().effective_raw_packet_retention_days + resp = client_no_auth.get( + f"/api/v1/dashboard/routes-overview?days={retention + 30}" + ) + assert resp.json()["days"] == retention + + def test_history_includes_today_segment(self, client_no_auth, api_db_session): + """Each route's history has ``days + 1`` entries (include_today).""" + _make_route_with_nodes(api_db_session, "A", "B", ["a" * 64, "b" * 64]) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/dashboard/routes-overview?days=7").json() + assert len(data["routes"]) == 1 + history = data["routes"][0]["history"] + # 7 calendar days + today segment = 8 entries. + assert len(history) == 8 + # Today's entry has the rolling-window state semantics. + today_entry = history[-1] + assert today_entry["quality"] in { + "clear", + "marginal", + "failing", + "unknown", + } + assert today_entry["state"] in { + "healthy", + "unhealthy", + "no_coverage", + } + + def test_visibility_filter_hides_admin_routes(self, client_no_auth, api_db_session): + """Anonymous users must not see admin-only routes in the overview.""" + _make_route_with_nodes( + api_db_session, + "Public", + "Endpoint", + ["a" * 64, "b" * 64], + visibility="community", + ) + _make_route_with_nodes( + api_db_session, + "Secret", + "Endpoint", + ["c" * 64, "d" * 64], + visibility="admin", + ) + api_db_session.commit() + + # Anonymous: only the community route. + anon = client_no_auth.get("/api/v1/dashboard/routes-overview").json() + labels = [r["from_label"] for r in anon["routes"]] + assert labels == ["Public"] + + # Admin sees both. + admin = client_no_auth.get( + "/api/v1/dashboard/routes-overview", + headers={"X-User-Roles": "admin"}, + ).json() + admin_labels = sorted(r["from_label"] for r in admin["routes"]) + assert admin_labels == ["Public", "Secret"] + + def test_by_state_buckets_current_state(self, client_no_auth, api_db_session): + """``by_state`` counts route current state across the fleet.""" + from meshcore_hub.collector.routes import ( + evaluate_route, + upsert_route_result, + ) + + # Healthy route (3 packets in window, low clear bar) → healthy. + healthy = _make_route_with_nodes( + api_db_session, + "Healthy", + "End", + ["a" * 64, "b" * 64], + threshold=3, + clear_bar=3, + ) + now = datetime.now(timezone.utc) + for i in range(3): + _make_reception_for_route( + api_db_session, + f"pk{i}", + ["AA", "BB"], + now - timedelta(hours=1, seconds=i), + ) + # Disabled route → state=disabled regardless of packets. + _make_route_with_nodes( + api_db_session, + "Disabled", + "End", + ["c" * 64, "e" * 64], + enabled=False, + ) + # Fresh route (no result yet, no packets) → no_coverage fallback. + _make_route_with_nodes( + api_db_session, + "Fresh", + "End", + ["f" * 64, "9" * 64], + ) + # Populate route_result for the healthy route like the evaluator does. + since = now - timedelta(hours=healthy.window_hours) + state, quality, matched = evaluate_route(api_db_session, healthy, since) + upsert_route_result(api_db_session, healthy, state, quality, matched) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/dashboard/routes-overview").json() + state_map = {b["label"]: b["count"] for b in data["by_state"]} + # Healthy route picks up `healthy`; disabled picks up `disabled`; + # fresh route with no route_result falls back to `no_coverage`. + assert state_map.get("healthy", 0) >= 1 + assert state_map.get("disabled", 0) >= 1 + assert state_map.get("no_coverage", 0) >= 1 + + # The healthy route's per-route entry carries the matching state. + healthy_entry = next(r for r in data["routes"] if r["from_label"] == "Healthy") + assert healthy_entry["state"] == "healthy" + assert healthy_entry["quality"] == "clear" + assert healthy_entry["matched_count"] == 3 + + def test_disabled_route_matched_count_is_none(self, client_no_auth, api_db_session): + _make_route_with_nodes( + api_db_session, + "Off", + "End", + ["a" * 64, "b" * 64], + enabled=False, + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/dashboard/routes-overview").json() + route = data["routes"][0] + assert route["state"] == "disabled" + assert route["matched_count"] is None + + def test_cache_key_is_role_scoped(self, client_no_auth, api_db_session): + """Different roles get independent cache entries (sanity: both + return 200, response differs when visibility-filtered).""" + from unittest.mock import MagicMock + + _make_route_with_nodes( + api_db_session, + "AdminOnly", + "End", + ["a" * 64, "b" * 64], + visibility="admin", + ) + api_db_session.commit() + + # Mock cache that records every set() call's key. + keys_seen: list[str] = [] + mock = MagicMock() + mock.get.return_value = None + + def _set(key, value, ttl): + keys_seen.append(key) + + mock.set.side_effect = _set + client_no_auth.app.state.redis_cache = mock + client_no_auth.app.state.redis_cache_ttl = 30 + + client_no_auth.get("/api/v1/dashboard/routes-overview") + client_no_auth.get( + "/api/v1/dashboard/routes-overview", + headers={"X-User-Roles": "admin"}, + ) + + # Two cache fills, role differs between them. + assert len(keys_seen) == 2 + assert any("anonymous" in k for k in keys_seen) + assert any("admin" in k for k in keys_seen) diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 209e4bf..8668ec2 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -270,7 +270,7 @@ class TestGetRouteDetail: mock_cache = MagicMock() mock_cache.get.return_value = None client_no_auth.app.state.redis_cache = mock_cache - client_no_auth.app.state.redis_cache_ttl_route_detail = 90 + client_no_auth.app.state.redis_cache_ttl_dashboard = 90 resp = client_no_auth.get(f"/api/v1/routes/{route.id}") assert resp.status_code == 200 @@ -315,7 +315,7 @@ class TestGetRouteDetail: return True client_no_auth.app.state.redis_cache = _FakeCache() - client_no_auth.app.state.redis_cache_ttl_route_detail = 60 + client_no_auth.app.state.redis_cache_ttl_dashboard = 60 first = client_no_auth.get(f"/api/v1/routes/{route.id}") assert first.status_code == 200 @@ -687,6 +687,63 @@ class TestRouteHistory: # retention=2 → days clamped to 2 → 3 entries (days + today) assert len(data["data"]) == 3 + def test_today_segment_matches_route_result_badge( + self, client_no_auth, api_db_session + ): + """The rightmost history segment must match route_result (the badge). + + Regression for the rolling-window/calendar-day mismatch: packets + placed yesterday evening are inside the rolling 24h window used by + the badge but outside today's UTC calendar bucket. After this fix, + the history endpoint's today segment must reflect the same + matched_count/quality/state as route_result. + """ + from datetime import timedelta + + from meshcore_hub.collector.routes import ( + evaluate_route, + upsert_route_result, + ) + + route = _make_route_with_nodes( + api_db_session, "A", "B", ["aa" + "0" * 62, "bb" + "0" * 62] + ) + api_db_session.commit() + + # Place 3 matching packets 6h ago — within the 24h rolling window. + # If "now" is early in the UTC day, this may land in yesterday's + # calendar bucket, which is exactly the scenario we're fixing. + now = datetime.now(timezone.utc) + for i in range(3): + _make_reception( + api_db_session, + None, + f"badge{i}", + ["AA", "BB"], + received_at=now - timedelta(hours=6, seconds=i), + ) + api_db_session.commit() + + # Populate route_result the same way the background evaluator does. + rolling_start = now - timedelta(hours=route.window_hours) + state, quality, matched = evaluate_route(api_db_session, route, rolling_start) + upsert_route_result(api_db_session, route, state, quality, matched) + api_db_session.commit() + api_db_session.refresh(route) + + badge = route.route_result + assert badge is not None, "fixture: route_result must be populated" + + # Bypass cache to read fresh state. + client_no_auth.app.dependency_overrides = {} + + resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=3") + assert resp.status_code == 200 + today_segment = resp.json()["data"][-1] + assert today_segment["matched_count"] == badge.matched_count + assert today_segment["quality"] == badge.quality + assert today_segment["state"] == badge.state + # --------------------------------------------------------------------------- # Additional coverage: update fields, observers, hidden detail, preview guards diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py index 3bb2e9c..c0d09ec 100644 --- a/tests/test_collector/test_routes.py +++ b/tests/test_collector/test_routes.py @@ -999,6 +999,91 @@ class TestEvaluateRouteHistory: assert by_date[clear_date][0] == RouteQuality.CLEAR.value assert by_date[marginal_date][0] == RouteQuality.MARGINAL.value + def test_today_segment_uses_rolling_window(self, db_session): + """include_today segment matches evaluate_route(now - window_hours), + not the calendar-day [00:00 UTC, now] bucket. + + Packets placed yesterday evening fall outside today's UTC calendar + bucket but inside the rolling 24h window — they must surface in the + appended today segment so the chart matches the badge. + """ + node_a = _make_node(db_session, "aa" + "0" * 62) + node_b = _make_node(db_session, "bb" + "0" * 62) + route = _make_route( + db_session, + "R1", + [node_a, node_b], + threshold=3, + clear_bar=3, + window_hours=24, + ) + + # Pick "now" early in the UTC day so yesterday evening is inside the + # 24h rolling window but outside today's calendar bucket. + now = datetime(2026, 7, 15, 3, 0, 0, tzinfo=timezone.utc) + # 3 packets at 21:00 yesterday UTC — within rolling window, + # before today_midnight (2026-07-15 00:00 UTC). + for i in range(3): + _make_reception( + db_session, + None, + f"rolling{i}", + ["AA", "BB"], + received_at=now - timedelta(hours=6, seconds=i), + ) + db_session.commit() + + results = evaluate_route_history( + db_session, route, days=3, include_today=True, now=now + ) + + assert len(results) == 4 + today_entry = results[-1] + assert today_entry[0] == now.date() + # Badge-aligned: 3 matches in the rolling window → HEALTHY/CLEAR + assert today_entry[1] == RouteQuality.CLEAR.value + assert today_entry[2] == RouteState.HEALTHY.value + assert today_entry[3] == 3 + + # Cross-check: the today segment must agree with evaluate_route + # over the same rolling window. + rolling_start = now - timedelta(hours=route.window_hours) + state, quality, matched = evaluate_route(db_session, route, rolling_start) + assert (today_entry[1], today_entry[2], today_entry[3]) == ( + quality, + state, + matched, + ) + + def test_today_segment_excludes_packets_outside_window(self, db_session): + """Packets older than window_hours must not count in today's segment.""" + node_a = _make_node(db_session, "aa" + "0" * 62) + node_b = _make_node(db_session, "bb" + "0" * 62) + # window_hours=6 — only packets in the last 6h count. + route = _make_route( + db_session, "R1", [node_a, node_b], threshold=1, window_hours=6 + ) + + now = datetime(2026, 7, 15, 3, 0, 0, tzinfo=timezone.utc) + # 1 packet at 18:00 yesterday UTC — older than the 6h window. + _make_reception( + db_session, + None, + "ancient", + ["AA", "BB"], + received_at=now - timedelta(hours=9), + ) + db_session.commit() + + results = evaluate_route_history( + db_session, route, days=3, include_today=True, now=now + ) + + today_entry = results[-1] + assert today_entry[0] == now.date() + assert today_entry[3] == 0 + assert today_entry[1] == RouteQuality.UNKNOWN.value + # --------------------------------------------------------------------------- # Branch-coverage tests (guards, exception handlers, optional-param paths) From fc60cd201a804ed05f5d841d88ba33e5af103fb7 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 17:23:54 +0100 Subject: [PATCH 2/2] 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) # ---------------------------------------------------------------------------