mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-28 12:32:28 +02:00
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:
+6
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user