diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index 6f4bf7e..ce57195 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -320,8 +320,16 @@ export async function fetchAlertHistory( return fetchJson(`/api/alerts/history?${params.toString()}`) } -export async function fetchActivity(limit = 100): Promise { - return fetchJson(`/api/activity?limit=${limit}`) +export async function fetchActivity( + limit = 100, + transport?: string, + category?: string, +): Promise { + const params = new URLSearchParams() + params.set('limit', limit.toString()) + if (transport && transport !== 'all') params.set('transport', transport) + if (category && category !== 'all') params.set('category', category) + return fetchJson(`/api/activity?${params.toString()}`) } export async function fetchEnvStatus(): Promise { diff --git a/work/dashboard-frontend/src/pages/ActivityLog.tsx b/work/dashboard-frontend/src/pages/ActivityLog.tsx index 1e3099d..b939b4f 100644 --- a/work/dashboard-frontend/src/pages/ActivityLog.tsx +++ b/work/dashboard-frontend/src/pages/ActivityLog.tsx @@ -49,10 +49,36 @@ function familyLabel(table: string | null): string { // --- component ------------------------------------------------------------- +// Transport filter options. +const TRANSPORTS = [ + { value: 'all', label: 'All meshes' }, + { value: 'meshtastic', label: 'Meshtastic' }, + { value: 'meshcore', label: 'MeshCore' }, +] + +// Category filter options. Values are the raw source_event_table stored on +// each broadcast row; labels are operator-friendly. Kept explicit (not +// derived from the current window) so lower-frequency categories like +// weather are reachable even when chatty satpass/band rows dominate the feed. +const CATEGORIES = [ + { value: 'all', label: 'All types' }, + { value: 'nws_alerts', label: 'Weather' }, + { value: 'fires', label: 'Fires' }, + { value: 'fire_digest_broadcasts', label: 'Fire digest' }, + { value: 'satpass_events', label: 'Satellite' }, + { value: 'band_conditions_broadcasts', label: 'Band' }, + { value: 'traffic_events', label: 'Traffic' }, +] + +const PAGE = 100 + export default function ActivityLog() { const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [transport, setTransport] = useState('all') + const [category, setCategory] = useState('all') + const [limit, setLimit] = useState(PAGE) useEffect(() => { document.title = 'Activity Log — MeshAI' @@ -61,7 +87,7 @@ export default function ActivityLog() { useEffect(() => { let alive = true const load = () => { - fetchActivity() + fetchActivity(limit, transport, category) .then((data) => { if (!alive) return setEntries(data) @@ -80,7 +106,7 @@ export default function ActivityLog() { alive = false clearInterval(interval) } - }, []) + }, [limit, transport, category]) if (loading) { return ( @@ -101,13 +127,36 @@ export default function ActivityLog() { return (
-
+

Activity Log

+ + {/* Filters — default 'all' shows the full feed (every category, + both meshes). Narrowing surfaces lower-frequency categories + (e.g. weather) and the MeshCore side. */} + + + - {entries.length} recent broadcast{entries.length === 1 ? '' : 's'} · newest first + {entries.length} broadcast{entries.length === 1 ? '' : 's'} · newest first
@@ -174,6 +223,21 @@ export default function ActivityLog() { })} )} + + {/* Load more — pages further back so lower-frequency categories + (weather, older MeshCore sends) remain reachable even when chatty + satpass/band broadcasts fill the newest window. Shown only when the + page came back full (more rows likely exist). */} + {entries.length >= limit && ( +
+ +
+ )}
) diff --git a/work/meshai/dashboard/api/alert_routes.py b/work/meshai/dashboard/api/alert_routes.py index a377f48..53e36d7 100644 --- a/work/meshai/dashboard/api/alert_routes.py +++ b/work/meshai/dashboard/api/alert_routes.py @@ -59,23 +59,51 @@ async def get_alert_history( @router.get("/activity") async def get_activity( request: Request, - limit: int = Query(100, ge=1, le=500), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + transport: Optional[str] = Query(None), + category: Optional[str] = Query(None), ): - """Activity Log: most recent outbound mesh broadcasts, newest first. + """Activity Log: outbound mesh broadcasts, newest first. - Reads mesh_broadcasts_out from the persistence/migration DB (get_db) and - returns every column as a plain dict. Legacy rows keep NULL - transport/success. If the table doesn't exist yet, returns []. + Reads the FULL mesh_broadcasts_out audit log from the persistence DB + (get_db) and returns every column as a plain dict. This is the whole + outbound feed -- every category (weather/fires/satpass/band/traffic) + across BOTH transports (meshtastic + meshcore). Nothing is filtered by + default; the chatty scheduled categories (satpass, band) no longer crowd + lower-frequency ones out of view because callers can page (offset) and + narrow (transport / category). + + Params: + limit -- page size (default 100, max 1000), newest-first. + offset -- rows to skip for pagination (default 0). + transport-- OPTIONAL: 'meshtastic' | 'meshcore'. Omit = both. + category -- OPTIONAL: source_event_table value (e.g. 'nws_alerts', + 'fires', 'satpass_events'). Omit = all categories. + + Legacy pre-audit rows keep NULL transport/success and are still returned + on the unfiltered feed. If the table doesn't exist yet, returns []. """ from meshai.persistence import get_db + where = [] + params: list = [] + if transport: + where.append("transport = ?") + params.append(transport) + if category: + where.append("source_event_table = ?") + params.append(category) + + sql = "SELECT * FROM mesh_broadcasts_out" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY sent_at DESC, id DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + try: conn = get_db() - rows = conn.execute( - "SELECT * FROM mesh_broadcasts_out " - "ORDER BY sent_at DESC, id DESC LIMIT ?", - (limit,), - ).fetchall() + rows = conn.execute(sql, params).fetchall() except Exception: return [] return [dict(r) for r in rows] diff --git a/work/tests/test_activity_log_api.py b/work/tests/test_activity_log_api.py new file mode 100644 index 0000000..6bc4106 --- /dev/null +++ b/work/tests/test_activity_log_api.py @@ -0,0 +1,109 @@ +"""API tests for the Activity Log endpoint (GET /api/activity). + +The Activity Log reads the FULL mesh_broadcasts_out audit log -- every +category (weather/fires/satpass/band/traffic) across BOTH transports +(meshtastic + meshcore), newest-first, with limit/offset pagination and +OPTIONAL transport/category filters that default to "everything". + +Uses FastAPI TestClient against the per-test tmp DB seeded by the conftest +autouse fixture; we insert a handful of mesh_broadcasts_out rows directly. +""" +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from meshai.dashboard.api.alert_routes import router +from meshai.persistence import get_db + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router, prefix="/api") + return TestClient(app) + + +def _seed(rows): + """Insert (sent_at, recipient, channel, text, table, pk, transport, success).""" + conn = get_db() + for sent_at, recipient, channel, text, table, pk, transport, success in rows: + conn.execute( + "INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, " + "text, source_event_table, source_event_pk, bytes_sent, " + "ack_received, transport, success) VALUES (?,?,?,?,?,?,?,?,?,?)", + (sent_at, recipient, channel, text, table, pk, + len(text.encode()), 0, transport, success), + ) + conn.commit() + + +# Deliberately mixed transports + categories, out of chronological order. +SEED = [ + (100, "broadcast", 0, "old weather MT", "nws_alerts", "a", "meshtastic", 1), + (200, "broadcast", "aida", "weather MC", "nws_alerts", "b", "meshcore", 1), + (300, "broadcast", 0, "satpass MT", "satpass_events", "c", "meshtastic", 1), + (400, "broadcast", "fire", "fire MC", "fires", "d", "meshcore", 1), + (500, "broadcast", 0, "newest band MT", "band_conditions_broadcasts", "e", + "meshtastic", 1), +] + + +def test_activity_returns_all_categories_and_both_meshes(client): + """The default (unfiltered) feed returns EVERY seeded row -- no category + or transport is dropped -- newest-first.""" + _seed(SEED) + r = client.get("/api/activity") + assert r.status_code == 200 + body = r.json() + assert len(body) == len(SEED) + + # newest-first ordering + assert [e["sent_at"] for e in body] == [500, 400, 300, 200, 100] + + # both transports present + assert {e["transport"] for e in body} == {"meshtastic", "meshcore"} + # weather (nws) present on BOTH meshes -- the categories the stub feed missed + nws = [e for e in body if e["source_event_table"] == "nws_alerts"] + assert {e["transport"] for e in nws} == {"meshtastic", "meshcore"} + # fire on the MeshCore side present + assert any( + e["source_event_table"] == "fires" and e["transport"] == "meshcore" + for e in body + ) + + +def test_activity_pagination_limit_and_offset(client): + """limit caps the page; offset walks further back, newest-first.""" + _seed(SEED) + page1 = client.get("/api/activity?limit=2").json() + assert [e["sent_at"] for e in page1] == [500, 400] + page2 = client.get("/api/activity?limit=2&offset=2").json() + assert [e["sent_at"] for e in page2] == [300, 200] + page3 = client.get("/api/activity?limit=2&offset=4").json() + assert [e["sent_at"] for e in page3] == [100] + + +def test_activity_optional_transport_filter(client): + """transport is optional and narrows to one mesh when supplied.""" + _seed(SEED) + mc = client.get("/api/activity?transport=meshcore").json() + assert {e["transport"] for e in mc} == {"meshcore"} + assert [e["sent_at"] for e in mc] == [400, 200] + + +def test_activity_optional_category_filter(client): + """category is optional and narrows to one source_event_table -- this is + how an operator surfaces weather when chatty satpass rows dominate.""" + _seed(SEED) + weather = client.get("/api/activity?category=nws_alerts").json() + assert {e["source_event_table"] for e in weather} == {"nws_alerts"} + assert [e["sent_at"] for e in weather] == [200, 100] + + +def test_activity_empty_when_no_rows(client): + """No broadcasts yet -> empty list, not an error.""" + r = client.get("/api/activity") + assert r.status_code == 200 + assert r.json() == []