feat(status): use SFM event forwards as primary seismograph last-seen, heartbeat as backup

emit_status_snapshot() now consults SFM /db/units (cached 15s) before
falling back to Emitter.last_seen for each seismograph. The fresher of
the two wins and the choice is recorded in a new per-unit
last_seen_source field ("sfm" | "heartbeat" | "none"). sfm_reachable is
exposed alongside so the UI can show degraded state.

Fallback is transparent: if SFM is unreachable or has no record for a
serial, the watcher heartbeat path takes over and the unit just shows
the HB badge instead of SFM. No schema changes; SLMs are untouched
(they don't go through SFM); modems inherit source from their pair.

active_table.html grows a small "SFM" / "HB" badge next to the age
column so operators can see at a glance which path is currently
driving each unit's status.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 22:58:34 +00:00
parent 18fd0472a5
commit 449e031589
2 changed files with 125 additions and 10 deletions
+8 -1
View File
@@ -36,7 +36,14 @@
</div>
<!-- Age -->
<div class="text-right flex-shrink-0">
<div class="text-right flex-shrink-0 flex items-center gap-2">
{% if unit.last_seen_source == 'sfm' %}
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-seismo-orange/10 text-seismo-orange font-semibold"
title="Status sourced from SFM event forwards (primary)">SFM</span>
{% elif unit.last_seen_source == 'heartbeat' %}
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300"
title="Status sourced from watcher heartbeat (backup)">HB</span>
{% endif %}
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
{{ unit.age }}
</span>