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).
This commit is contained in:
Louis King
2026-07-19 16:49:19 +01:00
parent f35be67da6
commit 37f8d7ae0b
27 changed files with 1241 additions and 220 deletions
+6 -5
View File
@@ -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
+1 -2
View File
@@ -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
+2 -3
View File
@@ -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
+25 -10
View File
@@ -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=<ttl>` 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=<ttl>` 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
+3 -6
View File
@@ -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,
@@ -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:
+6 -13
View File
@@ -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,
)
+224 -54
View File
@@ -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)
+1 -1
View File
@@ -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(
+32 -8
View File
@@ -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
+6 -7
View File
@@ -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(
+25 -8
View File
@@ -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."""
+35
View File
@@ -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()
+14
View File
@@ -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);
}
+142 -2
View File
@@ -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);
}
}
/**
+4 -4
View File
@@ -231,11 +231,11 @@ function renderMobileNav(config) {
if (features.advertisements !== false) {
items.push(html`<li><a href="/advertisements" data-nav-link>${iconAdvertisements('h-5 w-5 nav-icon-adverts')} ${t('entities.advertisements')}</a></li>`);
}
if (features.channels !== false) {
items.push(html`<li><a href="/channels" data-nav-link>${iconChannel('h-5 w-5')} ${t('entities.channels')}</a></li>`);
}
if (features.routes !== false) {
items.push(html`<li><a href="/routes" data-nav-link>${iconPath('h-5 w-5')} ${t('entities.routes')}</a></li>`);
items.push(html`<li><a href="/routes" data-nav-link>${iconPath('h-5 w-5 nav-icon-routes')} ${t('entities.routes')}</a></li>`);
}
if (features.channels !== false) {
items.push(html`<li><a href="/channels" data-nav-link>${iconChannel('h-5 w-5 nav-icon-channels')} ${t('entities.channels')}</a></li>`);
}
if (features.messages !== false) {
items.push(html`<li><a href="/messages" data-nav-link>${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}</a></li>`);
@@ -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`<button type="button"
class="${cls} cursor-pointer"
title=${title}
@click=${() => onToggle(area)}>${area}</button>`;
@click=${() => onToggle(n.public_key)}>${emoji ? html`<span class="mr-1">${emoji}</span>` : nothing}${label}</button>`;
})}
</div>`;
}
@@ -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`<p class="text-sm opacity-70">${t('dashboard.routes_empty')}</p>`;
}
// 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`
<div class="route-health-cell"
style="background:${colorFor(d.quality)}"
title="${d.date} \u2014 ${labelFor(d.quality)} (${d.matched_count})"></div>`);
const current = r.quality || (r.enabled ? 'no_coverage' : 'disabled');
return html`<div class="flex items-center gap-2">
<span class="flex-1 min-w-0 truncate text-sm"
title="${r.from_label} \u2192 ${r.to_label}">
${r.from_label} <span class="opacity-50">\u2192</span> ${r.to_label}
</span>
<div class="flex gap-0.5 flex-shrink-0">${cells}</div>
<span class="w-2 h-2 rounded-full flex-shrink-0"
style="background:${colorFor(current)}"
title="${labelFor(current)}"></span>
</div>`;
});
return html`<div class="space-y-2">
${rows}
${hidden > 0 ? html`<p class="text-xs opacity-60 pt-1">${t('dashboard.routes_more', { count: hidden })}</p>` : nothing}
</div>`;
}
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`
<div class="grid grid-cols-1 ${gridCols(visibleCount)} gap-6 mb-8">
@@ -213,8 +263,9 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s
</div>` : nothing}
</div>
${showPackets ? html`
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-8">
${(showPackets || (showRoutes && hasRoutes)) ? html`
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
${showPackets ? html`
<div class="card bg-base-100 shadow-xl panel-accent" style="--panel-color: var(--color-packets)">
<div class="card-body">
<div class="flex items-start justify-between gap-2">
@@ -233,8 +284,9 @@ ${showPackets ? html`
<canvas id="packetEventTypeChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
${showPackets ? html`
<div class="card bg-base-100 shadow-xl panel-accent" style="--panel-color: var(--color-packets)">
<div class="card-body">
<div class="flex items-start justify-between gap-2">
@@ -253,7 +305,39 @@ ${showPackets ? html`
<canvas id="packetPathWidthChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
${(showRoutes && hasRoutes) ? html`
<div class="card bg-base-100 shadow-xl panel-accent" style="--panel-color: var(--color-routes)">
<div class="card-body">
<div class="flex items-start justify-between gap-2">
<div>
<h2 class="card-title text-base">
${t('dashboard.route_health')}
</h2>
<p class="text-xs opacity-80">${t('time.last_7_days')}</p>
</div>
</div>
${renderRoutesHealth(routesOverview.routes)}
</div>
</div>` : nothing}
${(showRoutes && hasRoutes) ? html`
<div class="card bg-base-100 shadow-xl panel-accent" style="--panel-color: var(--color-routes)">
<div class="card-body">
<div class="flex items-start justify-between gap-2">
<div>
<h2 class="card-title text-base">
${t('dashboard.routes_trend')}
</h2>
<p class="text-xs opacity-80">${t('time.routes_over_last_n_days', { n: routesOverview.days })}</p>
</div>
</div>
<div class="h-32">
<canvas id="routesTrendChart"></canvas>
</div>
</div>
</div>` : nothing}
</div>` : 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) {
</div>
${(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`
<div class="grid grid-cols-1 ${bottomGrid} gap-6">
@@ -305,11 +392,11 @@ ${bottomCount > 0 ? html`
${iconAdvertisements('h-6 w-6')}
${t('common.recent_entity', { entity: t('entities.advertisements') })}
</h2>
${renderRecentAds(stats.recent_advertisements)}
${renderRecentAds(recentActivity.recent_advertisements)}
</div>
</div>` : nothing}
${showMessages ? renderChannelMessages(stats.channel_messages, channelLabels) : nothing}
${showMessages ? renderChannelMessages(recentActivity.channel_messages, channelLabels) : nothing}
</div>` : 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);
@@ -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'),
@@ -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`<span class="badge ${badgeCls} badge-sm tooltip tooltip-left" data-tip=${tip}>${dot} ${label}</span>`
: html`<span class="badge ${badgeCls} badge-sm">${dot} ${label}</span>`;
@@ -158,7 +157,6 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, p
${route.description ? html`<p class="text-sm opacity-70 mt-1">${route.description}</p>` : nothing}
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="badge badge-ghost badge-sm opacity-60 font-mono text-xl leading-none px-2 inline-flex items-center" title=${route.reversible !== false ? t('routes.reversible_label') : ''}>${arrow}</span>
${badge}
</div>
</div>
@@ -181,7 +179,7 @@ function renderDetailContent(route, detail, { navigate, packetsEnabled, history
<canvas id="routeStripChart-${route.id}"></canvas>
</div>
${history.data && history.data.length > 0 ? html`<div class="flex text-xs opacity-50 mt-0.5">
${history.data.map(d => html`<span class="flex-1 text-center">${new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}</span>`)}
${history.data.map((d, i) => html`<span class="flex-1 text-center">${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' })}</span>`)}
</div>` : nothing}
</div>`
: nothing;
+10 -3
View File
@@ -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."
+10 -3
View File
@@ -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."
+3 -3
View File
@@ -70,12 +70,12 @@
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
{% endif %}
{% if features.channels %}
<li><a href="/channels" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
{% endif %}
{% if features.routes %}
<li><a href="/routes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-routes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" /></svg> {{ t('entities.routes') }}</a></li>
{% endif %}
{% if features.channels %}
<li><a href="/channels" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-channels" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
{% endif %}
+24 -15
View File
@@ -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 ----------------------------------------------------
+410 -51
View File
@@ -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)
+59 -2
View File
@@ -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
+85
View File
@@ -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)