fix(dashboard): Activity Log shows the full broadcast log (all categories, both meshes)

The Activity Log endpoint wasn't reading mesh_broadcasts_out, so it only
surfaced a partial set (MT band-propagation + satpass) and missed the
event-driven weather broadcasts and the entire MeshCore side. Query
mesh_broadcasts_out for all broadcasts across both transports and all
categories, newest-first with pagination, so the feed reflects everything
that actually went to the mesh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-07 01:27:06 +00:00
commit a19da0a87f
4 changed files with 226 additions and 17 deletions

View file

@ -320,8 +320,16 @@ export async function fetchAlertHistory(
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
}
export async function fetchActivity(limit = 100): Promise<ActivityEntry[]> {
return fetchJson<ActivityEntry[]>(`/api/activity?limit=${limit}`)
export async function fetchActivity(
limit = 100,
transport?: string,
category?: string,
): Promise<ActivityEntry[]> {
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<ActivityEntry[]>(`/api/activity?${params.toString()}`)
}
export async function fetchEnvStatus(): Promise<EnvStatus> {

View file

@ -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<ActivityEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
<div className="bg-bg-card border border-border">
<div className="p-4 border-b border-border flex items-center gap-2">
<div className="p-4 border-b border-border flex items-center flex-wrap gap-2">
<Activity size={14} className="text-[#f59e0b]" />
<h2 className="text-sm font-medium text-slate-300">
Activity Log
</h2>
{/* Filters default 'all' shows the full feed (every category,
both meshes). Narrowing surfaces lower-frequency categories
(e.g. weather) and the MeshCore side. */}
<select
value={transport}
onChange={(e) => { setTransport(e.target.value); setLimit(PAGE) }}
className="text-xs bg-bg-hover text-slate-300 border border-border rounded px-2 py-1"
>
{TRANSPORTS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<select
value={category}
onChange={(e) => { setCategory(e.target.value); setLimit(PAGE) }}
className="text-xs bg-bg-hover text-slate-300 border border-border rounded px-2 py-1"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<span className="text-xs text-slate-500 ml-auto">
{entries.length} recent broadcast{entries.length === 1 ? '' : 's'} · newest first
{entries.length} broadcast{entries.length === 1 ? '' : 's'} · newest first
</span>
</div>
@ -174,6 +223,21 @@ export default function ActivityLog() {
})}
</ul>
)}
{/* 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 && (
<div className="p-3 border-t border-border flex justify-center">
<button
onClick={() => setLimit((n) => n + PAGE)}
className="text-xs px-3 py-1 rounded bg-bg-hover text-slate-300 border border-border hover:bg-bg-card transition-colors"
>
Load more
</button>
</div>
)}
</div>
</div>
)

View file

@ -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]

View file

@ -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() == []