diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index a19df26..d26772b 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -185,12 +185,19 @@ def get_stats( advertisements_24h = adv_row[1] or 0 advertisements_7d = adv_row[2] or 0 - # Raw-packet counts (total + last 7 days), observer-level volume metric + # Raw-packet counts (total + recent window), observer-level volume metric # with no role/channel filter (payload redaction lives on list/detail only). + # The "recent" window is capped at min(7, retention) because raw packets + # (and their cascaded packet_path_hops) are purged at RAW_PACKET_RETENTION_DAYS; + # a wider window would silently undercount against the "Last 7 days" label. + packets_window_days = min( + 7, get_collector_settings().effective_raw_packet_retention_days + ) + packets_since = now - timedelta(days=packets_window_days) packet_row = session.execute( select( func.count(RawPacket.id).label("total_packets"), - func.sum(case((RawPacket.received_at >= seven_days_ago, 1), else_=0)).label( + func.sum(case((RawPacket.received_at >= packets_since, 1), else_=0)).label( "packets_7d" ), ).select_from(RawPacket) @@ -252,6 +259,7 @@ def get_stats( total_members=total_members, total_packets=total_packets, packets_7d=packets_7d, + packets_window_days=packets_window_days, ) @@ -470,7 +478,14 @@ def get_packet_activity( Returns: Daily raw-packet counts for each day in the period (excluding today) """ - days = min(days, 90) + # Clamp to the raw-packet retention window so the response never advertises + # days whose data has been purged (the route evaluator / dashboard stat uses + # the same cap). See get_dashboard_stats for the rationale. + days = min( + days, + 90, + get_collector_settings().effective_raw_packet_retention_days, + ) now = datetime.now(timezone.utc) end_date = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -524,7 +539,12 @@ def get_packet_breakdown( Counts bucketed by event type (top 6 + "other") and by path-hash byte width (1b/2b/3b, NULL excluded) for the period (excluding today). """ - days = min(days, 90) + # Clamp to the raw-packet retention window (see get_dashboard_stats). + days = min( + days, + 90, + get_collector_settings().effective_raw_packet_retention_days, + ) now = datetime.now(timezone.utc) end_date = now.replace(hour=0, minute=0, second=0, microsecond=0) diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index fadeab4..c902907 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -296,6 +296,14 @@ class DashboardStats(BaseModel): ) total_packets: int = Field(default=0, description="Total raw packets captured") packets_7d: int = Field(default=0, description="Packets captured in last 7 days") + packets_window_days: int = Field( + default=7, + description=( + "Actual window in days for packets_7d = min(7, " + "raw_packet_retention_days). The UI labels the stat with this " + "value so it never claims a 7-day window when retention is shorter." + ), + ) class RecentActivity(BaseModel): diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx index 6edc257..f3bfa5f 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx @@ -41,9 +41,11 @@ interface DashboardStats { advertisements_7d: number; messages_7d: number; packets_7d: number; + packets_window_days: number; } interface PacketBreakdown { + days: number; by_event_type: BreakdownBucket[]; by_path_width: BreakdownBucket[]; } @@ -491,7 +493,9 @@ export function DashboardPage() { colorVar="--color-packets" icon={} title={t("entities.packets")} - subtitle={t("time.per_day_last_7_days")} + subtitle={t("time.per_day_last_n_days", { + n: data.packetActivity?.days ?? 7, + })} value={stats.packets_7d} > } title={t("entities.packet_event_types")} - subtitle={t("time.last_7_days")} + subtitle={t("time.last_n_days", { + n: packetBreakdown?.days ?? 7, + })} value={eventTypeTotal} > } title={t("entities.path_hash_width")} - subtitle={t("time.last_7_days")} + subtitle={t("time.last_n_days", { + n: packetBreakdown?.days ?? 7, + })} value={pathWidthTotal} > )} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/charts.ts b/src/meshcore_hub/web/static/js/spa-react/utils/charts.ts index 8c60d8f..0627da6 100644 --- a/src/meshcore_hub/web/static/js/spa-react/utils/charts.ts +++ b/src/meshcore_hub/web/static/js/spa-react/utils/charts.ts @@ -16,6 +16,9 @@ export interface ActivityPoint { } export interface ActivitySeries { + // `days` is present on all DailyActivity API responses but optional here so + // the type can be reused for activity charts that don't surface the window. + days?: number; data: ActivityPoint[]; } diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 3a0aab6..0eb82a3 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -137,7 +137,9 @@ "minutes_ago": "{{count}}m ago", "less_than_minute": "<1m ago", "last_7_days": "Last 7 days", + "last_n_days": "Last {{n}} days", "per_day_last_7_days": "Per day (last 7 days)", + "per_day_last_n_days": "Per day (last {{n}} days)", "over_time_last_7_days": "Over time (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)" diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index a041a19..59e0a9f 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -128,7 +128,9 @@ "minutes_ago": "{{count}}m geleden", "less_than_minute": "<1m geleden", "last_7_days": "Laatste 7 dagen", + "last_n_days": "Laatste {{n}} dagen", "per_day_last_7_days": "Per dag (laatste 7 dagen)", + "per_day_last_n_days": "Per dag (laatste {{n}} dagen)", "over_time_last_7_days": "In de tijd (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)" diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index 091d836..04fc575 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -51,6 +51,21 @@ class TestDateBucketKey: class TestDashboardStats: """Tests for GET /dashboard/stats endpoint.""" + @pytest.fixture(autouse=True) + def _high_raw_packet_retention(self, monkeypatch): + """Bump raw-packet retention to 90d for this class. + + The packet stat window is min(7, retention); at the default retention + of 2d it would clamp to 2d and break the days-handling assertions + below. The clamp itself is covered by + ``test_packets_window_days_tracks_retention``. + """ + from meshcore_hub.common.config import CollectorSettings + + monkeypatch.setattr( + CollectorSettings, "effective_raw_packet_retention_days", 90 + ) + def test_get_stats_empty(self, client_no_auth): """Test getting stats with empty database.""" response = client_no_auth.get("/api/v1/dashboard/stats") @@ -158,6 +173,43 @@ class TestDashboardStats: assert data["total_packets"] == 3 assert data["packets_7d"] == 2 # now + 3d (10d excluded) + def test_packets_window_days_tracks_retention( + self, client_no_auth, api_db_session, monkeypatch + ): + """The packet stat window is min(7, retention) and the count respects it. + + With the default retention of 2d, a packet 3 days ago falls outside + the window even though it would be inside a 7d window — so packets_7d + undercounts relative to the old fixed-7d behaviour, and the response + exposes the actual window via packets_window_days so the UI can label + it honestly ("Last 2 days") instead of lying ("Last 7 days"). + """ + from meshcore_hub.common.config import CollectorSettings + + now = datetime.now(timezone.utc) + api_db_session.add_all( + [ + RawPacket(event_type="message", received_at=now), + RawPacket(event_type="message", received_at=now - timedelta(days=3)), + ] + ) + api_db_session.commit() + + # Default retention (2d): window clamps to 2, so the 3-day-old packet + # is excluded and the window is exposed for honest labelling. + monkeypatch.setattr(CollectorSettings, "effective_raw_packet_retention_days", 2) + data = client_no_auth.get("/api/v1/dashboard/stats").json() + assert data["packets_window_days"] == 2 + assert data["packets_7d"] == 1 # only "now"; 3d is outside the 2d window + + # Retention >= 7: window is the full 7 days, both packets counted. + monkeypatch.setattr( + CollectorSettings, "effective_raw_packet_retention_days", 30 + ) + data = client_no_auth.get("/api/v1/dashboard/stats").json() + assert data["packets_window_days"] == 7 + assert data["packets_7d"] == 2 + class TestDashboardHtmlRemoved: """Tests that legacy HTML dashboard endpoint has been removed.""" @@ -296,6 +348,17 @@ class TestDashboardActivity: class TestPacketActivity: """Tests for GET /dashboard/packet-activity endpoint.""" + @pytest.fixture(autouse=True) + def _high_raw_packet_retention(self, monkeypatch): + """Bump raw-packet retention to 90d so the days-handling tests below + exercise the request parameter (the retention clamp is covered by + ``test_days_clamped_to_retention``).""" + from meshcore_hub.common.config import CollectorSettings + + monkeypatch.setattr( + CollectorSettings, "effective_raw_packet_retention_days", 90 + ) + def test_get_packet_activity_empty(self, client_no_auth): """Test getting packet activity with empty database.""" response = client_no_auth.get("/api/v1/dashboard/packet-activity") @@ -323,6 +386,18 @@ class TestPacketActivity: assert data["days"] == 90 assert len(data["data"]) == 90 + def test_days_clamped_to_retention(self, client_no_auth, monkeypatch): + """Days are clamped to the raw-packet retention window so the chart + never advertises days whose data has been purged.""" + from meshcore_hub.common.config import CollectorSettings + + monkeypatch.setattr(CollectorSettings, "effective_raw_packet_retention_days", 2) + response = client_no_auth.get("/api/v1/dashboard/packet-activity?days=30") + assert response.status_code == 200 + data = response.json() + assert data["days"] == 2 + assert len(data["data"]) == 2 + def test_get_packet_activity_with_data(self, client_no_auth, api_db_session): """Test getting packet activity with packets across two days. @@ -391,6 +466,17 @@ class TestPacketActivity: class TestPacketBreakdown: """Tests for GET /dashboard/packet-breakdown endpoint.""" + @pytest.fixture(autouse=True) + def _high_raw_packet_retention(self, monkeypatch): + """Bump raw-packet retention to 90d so the days-handling tests below + exercise the request parameter (the retention clamp is covered by + ``test_days_clamped_to_retention``).""" + from meshcore_hub.common.config import CollectorSettings + + monkeypatch.setattr( + CollectorSettings, "effective_raw_packet_retention_days", 90 + ) + def test_get_packet_breakdown_empty(self, client_no_auth): """Empty database returns empty bucket lists.""" response = client_no_auth.get("/api/v1/dashboard/packet-breakdown") @@ -418,6 +504,17 @@ class TestPacketBreakdown: data = response.json() assert data["days"] == 90 + def test_days_clamped_to_retention(self, client_no_auth, monkeypatch): + """Days are clamped to the raw-packet retention window so the breakdown + never advertises days whose data has been purged.""" + from meshcore_hub.common.config import CollectorSettings + + monkeypatch.setattr(CollectorSettings, "effective_raw_packet_retention_days", 2) + response = client_no_auth.get("/api/v1/dashboard/packet-breakdown?days=14") + assert response.status_code == 200 + data = response.json() + assert data["days"] == 2 + def test_breakdown_excludes_today(self, client_no_auth, api_db_session): """Today's packets are excluded from the breakdown window.""" now = datetime.now(timezone.utc)