mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
3fb4e6e65c
commit
a19da0a87f
4 changed files with 226 additions and 17 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
109
work/tests/test_activity_log_api.py
Normal file
109
work/tests/test_activity_log_api.py
Normal 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() == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue