diff --git a/sfm/database.py b/sfm/database.py index 8c492f5..156d31d 100644 --- a/sfm/database.py +++ b/sfm/database.py @@ -564,21 +564,79 @@ class SeismoDb: def query_units(self) -> list[dict]: """ - Return one row per known serial with summary stats: - last_seen, total_events, total_monitor_entries. + Return one row per known serial with summary stats. + + Aggregates from BOTH source tables: + - `events` — populated by every ingest path + (live ACH, /db/import/blastware_file + from the series3-watcher forwarder, etc.) + - `ach_sessions` — only populated by the live ACH server; + empty for events that came in via the + BW-importer route. + + Earlier this method only joined on `ach_sessions`, which made + watcher-forwarded units invisible to the SFM webapp's fleet + overview even though their events were correctly populated in + `events`. Now we union the two and surface every serial that + has activity in either table. + + Fields: + serial — unit serial number (e.g. "BE11529") + last_seen — most recent of MAX(events.timestamp) + and MAX(ach_sessions.session_time) + total_events — COUNT(*) from `events` (the + authoritative count regardless of + ingest path) + total_monitor_entries — from `ach_sessions`, 0 when absent + total_sessions — COUNT(*) from `ach_sessions`, 0 when absent """ with self._connect() as conn: - rows = conn.execute( - """ - SELECT - s.serial, - MAX(s.session_time) AS last_seen, - SUM(s.events_downloaded) AS total_events, - SUM(s.monitor_entries) AS total_monitor_entries, - COUNT(*) AS total_sessions - FROM ach_sessions s - GROUP BY s.serial - ORDER BY last_seen DESC - """ - ).fetchall() - return [dict(r) for r in rows] + event_stats = { + row["serial"]: row + for row in conn.execute( + """ + SELECT serial, + MAX(timestamp) AS last_event_at, + COUNT(*) AS total_events + FROM events + GROUP BY serial + """, + ).fetchall() + } + session_stats = { + row["serial"]: row + for row in conn.execute( + """ + SELECT serial, + MAX(session_time) AS last_session_at, + SUM(monitor_entries) AS total_monitor_entries, + COUNT(*) AS total_sessions + FROM ach_sessions + GROUP BY serial + """, + ).fetchall() + } + + all_serials = set(event_stats) | set(session_stats) + units = [] + for serial in all_serials: + e = event_stats.get(serial) + s = session_stats.get(serial) + last_event_at = e["last_event_at"] if e else None + last_session_at = s["last_session_at"] if s else None + # Prefer whichever timestamp is more recent + last_seen = max( + (t for t in (last_event_at, last_session_at) if t), + default=None, + ) + units.append({ + "serial": serial, + "last_seen": last_seen, + "total_events": e["total_events"] if e else 0, + "total_monitor_entries": s["total_monitor_entries"] if s else 0, + "total_sessions": s["total_sessions"] if s else 0, + }) + + # Sort by last_seen desc; serials with no timestamp at all sink to the bottom. + units.sort(key=lambda u: u.get("last_seen") or "", reverse=True) + return units