2 Commits

Author SHA1 Message Date
serversdown 449e031589 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>
2026-05-13 22:58:34 +00:00
serversdown 18fd0472a5 feat(dashboard): reorder top row, move schedule below map, source call-ins from SFM
- Top row left→right: Recent Alerts | Recent Call-Ins (2 cols) | Fleet Summary
- Today's Schedule becomes a horizontal collapsible card below Fleet Map.
  Collapsed by default; auto-expands when pending actions are detected in
  the rendered partial; manual toggle sticks via localStorage.
- New /api/recent-event-callins proxies SFM /db/events and bulk-joins each
  serial against RosterUnit for in-roster annotation. Phases the
  heartbeat-derived /api/recent-callins out of the UI while keeping it as
  a backup endpoint for now.
- Call-ins card renders a dense 2-column grid (last 10 events) showing
  PVS, sensor_location, false-trigger badge, event timestamp, and
  links to the unit page when rostered.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:58:25 +00:00
4 changed files with 435 additions and 147 deletions
+101
View File
@@ -4,11 +4,29 @@ from sqlalchemy import desc
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any from typing import List, Dict, Any
import os
import logging
import httpx
from backend.database import get_db from backend.database import get_db
from backend.models import UnitHistory, Emitter, RosterUnit from backend.models import UnitHistory, Emitter, RosterUnit
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["activity"]) router = APIRouter(prefix="/api", tags=["activity"])
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
def _humanize_age(seconds: float) -> str:
if seconds < 60:
return "just now"
if seconds < 3600:
return f"{int(seconds / 60)}m ago"
if seconds < 86400:
hrs = seconds / 3600
return f"{int(hrs)}h {int((hrs % 1) * 60)}m ago"
return f"{int(seconds / 86400)}d ago"
PHOTOS_BASE_DIR = Path("data/photos") PHOTOS_BASE_DIR = Path("data/photos")
@@ -144,3 +162,86 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
"hours": hours, "hours": hours,
"time_threshold": time_threshold.isoformat() "time_threshold": time_threshold.isoformat()
} }
@router.get("/recent-event-callins")
async def get_recent_event_callins(limit: int = 10, db: Session = Depends(get_db)):
"""
Recent unit call-ins derived from SFM event forwards.
Architecture context: the live ACH replacement is on hold, so call-homes
arrive as Blastware ACH event files forwarded by series3-watcher and
landed in the SFM events store. One event ≈ one call-in. This is the
forward-looking source of "recent call-ins" that will eventually replace
the heartbeat-based /recent-callins endpoint entirely.
Each row represents one event; multiple consecutive events from the same
serial are intentionally NOT collapsed — each one is a distinct call-home.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{SFM_BASE_URL}/db/events",
params={"limit": limit},
)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPError as e:
log.warning("SFM /db/events failed for recent-event-callins: %s", e)
return {"call_ins": [], "total": 0, "error": str(e)}
events = payload.get("events", []) or []
# Bulk-resolve serials → roster (single query, no N+1)
serials = list({ev.get("serial") for ev in events if ev.get("serial")})
roster_map: Dict[str, RosterUnit] = {}
if serials:
roster_map = {
r.id: r
for r in db.query(RosterUnit).filter(RosterUnit.id.in_(serials)).all()
}
now = datetime.now(timezone.utc)
call_ins: List[Dict[str, Any]] = []
for ev in events:
serial = ev.get("serial")
if not serial:
continue
roster = roster_map.get(serial)
# created_at = when SFM received the forward. Falls back to the event
# timestamp if the SFM payload didn't carry created_at (older rows).
created_at_str = ev.get("created_at") or ev.get("timestamp")
time_ago = ""
if created_at_str:
try:
ts = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
time_ago = _humanize_age((now - ts).total_seconds())
except ValueError:
pass
call_ins.append({
"unit_id": serial,
"serial": serial,
"event_id": ev.get("id"),
"event_timestamp": ev.get("timestamp"),
"created_at": ev.get("created_at"),
"time_ago": time_ago,
"peak_vector_sum": ev.get("peak_vector_sum"),
"false_trigger": bool(ev.get("false_trigger")),
"sensor_location": ev.get("sensor_location") or "",
"project": ev.get("project") or "",
"device_type": roster.device_type if roster else "seismograph",
"in_roster": roster is not None,
"note": (roster.note if roster else "") or "",
})
return {
"call_ins": call_ins,
"total": len(call_ins),
"source": "sfm-events",
}
+117 -9
View File
@@ -1,9 +1,77 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import logging
import os
import threading
import time
from typing import Optional
import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db_session from backend.database import get_db_session
from backend.models import Emitter, RosterUnit, IgnoredUnit from backend.models import Emitter, RosterUnit, IgnoredUnit
log = logging.getLogger(__name__)
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
# Tiny module-level cache: /api/status-snapshot is polled every 10s by the
# dashboard, and we don't want to hammer SFM with one /db/units roundtrip per
# call. 15s TTL keeps the cache mostly hot, with occasional refreshes.
_SFM_CACHE_TTL_SECONDS = 15.0
_sfm_cache_lock = threading.Lock()
_sfm_cache: dict = {"fetched_at": 0.0, "data": None, "reachable": False}
def _parse_sfm_timestamp(ts_str: Optional[str]) -> Optional[datetime]:
"""SFM /db/units returns naive ISO timestamps (no tz suffix). Treat them
as UTC, mirroring how the watcher heartbeat stores Emitter.last_seen."""
if not ts_str:
return None
try:
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
except ValueError:
return None
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts
def fetch_sfm_unit_last_seen() -> tuple[dict[str, datetime], bool]:
"""Return ({serial: last_seen_utc}, sfm_reachable).
Cached for _SFM_CACHE_TTL_SECONDS. On any HTTP error returns ({}, False)
so callers transparently fall back to the watcher-heartbeat path.
"""
now = time.monotonic()
with _sfm_cache_lock:
if _sfm_cache["data"] is not None and (now - _sfm_cache["fetched_at"]) < _SFM_CACHE_TTL_SECONDS:
return _sfm_cache["data"], _sfm_cache["reachable"]
data: dict[str, datetime] = {}
reachable = False
try:
with httpx.Client(timeout=4.0) as client:
resp = client.get(f"{SFM_BASE_URL}/db/units")
resp.raise_for_status()
payload = resp.json() or []
for row in payload:
serial = row.get("serial")
ts = _parse_sfm_timestamp(row.get("last_seen"))
if serial and ts is not None:
data[serial] = ts
reachable = True
except httpx.HTTPError as e:
log.warning("SFM /db/units unreachable for status snapshot: %s", e)
except Exception as e: # noqa: BLE001 — defensive against malformed payload
log.warning("SFM /db/units parse error: %s", e)
with _sfm_cache_lock:
_sfm_cache["fetched_at"] = now
_sfm_cache["data"] = data
_sfm_cache["reachable"] = reachable
return data, reachable
def ensure_utc(dt): def ensure_utc(dt):
if dt is None: if dt is None:
@@ -69,6 +137,11 @@ def emit_status_snapshot():
emitters = {e.id: e for e in db.query(Emitter).all()} emitters = {e.id: e for e in db.query(Emitter).all()}
ignored = {i.id for i in db.query(IgnoredUnit).all()} ignored = {i.id for i in db.query(IgnoredUnit).all()}
# SFM event-forwards are now the primary "last seen" signal for
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
# or hasn't seen a serial, we fall back to Emitter.last_seen.
sfm_last_seen_map, sfm_reachable = fetch_sfm_unit_last_seen()
units = {} units = {}
# --- Merge roster entries first --- # --- Merge roster entries first ---
@@ -93,24 +166,49 @@ def emit_status_snapshot():
last_seen = None last_seen = None
fname = "" fname = ""
else: else:
if e: device_type = r.device_type or "seismograph"
last_seen = ensure_utc(e.last_seen) emitter_last_seen = ensure_utc(e.last_seen) if e else None
# RECALCULATE status based on current time, not stored value fname = e.last_file if e else ""
# SFM-primary, heartbeat-backup logic — only for seismographs.
# (SLMs / modems aren't forwarded into SFM's events store.)
sfm_last_seen = sfm_last_seen_map.get(unit_id) if device_type == "seismograph" else None
if sfm_last_seen and emitter_last_seen:
# Both sources reported — use whichever is more recent.
if sfm_last_seen >= emitter_last_seen:
last_seen = sfm_last_seen
last_seen_source = "sfm"
else:
last_seen = emitter_last_seen
last_seen_source = "heartbeat"
elif sfm_last_seen:
last_seen = sfm_last_seen
last_seen_source = "sfm"
elif emitter_last_seen:
last_seen = emitter_last_seen
# If SFM was reachable but doesn't have this serial, it
# means the unit is calling home to the watcher but not
# being forwarded — still a working state for now.
last_seen_source = "heartbeat"
else:
last_seen = None
last_seen_source = "none"
if last_seen is not None:
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold) status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
age = format_age(last_seen) age = format_age(last_seen)
fname = e.last_file
else: else:
# Rostered but no emitter data
status = "Missing" status = "Missing"
last_seen = None
age = "N/A" age = "N/A"
fname = ""
units[unit_id] = { units[unit_id] = {
"id": unit_id, "id": unit_id,
"status": status, "status": status,
"age": age, "age": age,
"last": last_seen.isoformat() if last_seen else None, "last": last_seen.isoformat() if last_seen else None,
"last_seen_source": last_seen_source,
"sfm_reachable": sfm_reachable,
"fname": fname, "fname": fname,
"deployed": r.deployed, "deployed": r.deployed,
"note": r.note or "", "note": r.note or "",
@@ -136,14 +234,23 @@ def emit_status_snapshot():
# --- Add unexpected emitter-only units --- # --- Add unexpected emitter-only units ---
for unit_id, e in emitters.items(): for unit_id, e in emitters.items():
if unit_id not in roster: if unit_id not in roster:
last_seen = ensure_utc(e.last_seen) emitter_last_seen = ensure_utc(e.last_seen)
sfm_last_seen = sfm_last_seen_map.get(unit_id)
if sfm_last_seen and (not emitter_last_seen or sfm_last_seen >= emitter_last_seen):
last_seen = sfm_last_seen
last_seen_source = "sfm"
else:
last_seen = emitter_last_seen
last_seen_source = "heartbeat"
# RECALCULATE status for unknown units too # RECALCULATE status for unknown units too
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold) status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
units[unit_id] = { units[unit_id] = {
"id": unit_id, "id": unit_id,
"status": status, "status": status,
"age": format_age(last_seen), "age": format_age(last_seen),
"last": last_seen.isoformat(), "last": last_seen.isoformat() if last_seen else None,
"last_seen_source": last_seen_source,
"sfm_reachable": sfm_reachable,
"fname": e.last_file, "fname": e.last_file,
"deployed": False, # default "deployed": False, # default
"note": "", "note": "",
@@ -192,6 +299,7 @@ def emit_status_snapshot():
unit_data["status"] = paired_unit.get("status", "Missing") unit_data["status"] = paired_unit.get("status", "Missing")
unit_data["age"] = paired_unit.get("age", "N/A") unit_data["age"] = paired_unit.get("age", "N/A")
unit_data["last"] = paired_unit.get("last") unit_data["last"] = paired_unit.get("last")
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
unit_data["derived_from"] = paired_unit_id unit_data["derived_from"] = paired_unit_id
# Separate buckets for UI # Separate buckets for UI
+202 -130
View File
@@ -29,7 +29,55 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Fleet Summary Card --> <!-- Recent Alerts Card (col 1) -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
</path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
</div>
</div>
<!-- Recent Call-Ins Card (cols 2-3, double-wide) -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 md:col-span-2 lg:col-span-2" id="recent-callins-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
<span class="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline">from SFM event forwards</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
</path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div class="card-content" id="recent-callins-content">
<div id="recent-callins-list" class="space-y-2">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
</div>
<a href="/sfm" class="block mt-3 text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
View all events →
</a>
</div>
</div>
<!-- Fleet Summary Card (col 4) -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
@@ -121,74 +169,6 @@
</div> </div>
</div> </div>
<!-- Recent Alerts Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
</path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
</div>
</div>
<!-- Recently Called In Units Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
</path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div class="card-content" id="recent-callins-content">
<div id="recent-callins-list" class="space-y-2">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
</div>
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
Show all recent call-ins
</button>
</div>
</div>
<!-- Today's Scheduled Actions Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
</path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div class="card-content" id="todays-actions-content"
hx-get="/dashboard/todays-actions"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
</div>
</div>
</div> </div>
<!-- Dashboard Filters --> <!-- Dashboard Filters -->
@@ -269,6 +249,36 @@
</div> </div>
</div> </div>
<!-- Today's Schedule — horizontal collapsible card.
Default collapsed; auto-expands when an upcoming action is detected
(pending + scheduled within the next 4h). JS reads
data-has-upcoming on the inner partial after htmx swap. -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-8" id="todays-actions-card">
<div class="flex items-center justify-between cursor-pointer" onclick="toggleTodaysSchedule()">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
<span id="todays-actions-badge"
class="hidden text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
</span>
</div>
<svg class="w-5 h-5 text-gray-500 transition-transform collapsed" id="todays-actions-chevron"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<div class="card-content collapsed mt-4" id="todays-actions-content"
hx-get="/dashboard/todays-actions"
hx-trigger="load, every 30s"
hx-swap="innerHTML"
hx-on::after-swap="onTodaysActionsSwap(this)">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
</div>
</div>
<!-- Recent Photos Section --> <!-- Recent Photos Section -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
@@ -364,6 +374,17 @@
transform: rotate(-90deg); transform: rotate(-90deg);
} }
} }
/* Today's Schedule — horizontal collapsible at all breakpoints. */
#todays-actions-content.collapsed {
display: none;
}
#todays-actions-chevron.collapsed {
transform: rotate(-90deg);
}
#todays-actions-chevron {
transition: transform 0.2s ease-in-out;
}
</style> </style>
@@ -654,7 +675,8 @@ function toggleCard(cardName) {
// Restore card states from localStorage on page load // Restore card states from localStorage on page load
function restoreCardStates() { function restoreCardStates() {
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}'); const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status']; // Note: todays-actions has its own collapse handling (see toggleTodaysSchedule / onTodaysActionsSwap)
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
cardNames.forEach(cardName => { cardNames.forEach(cardName => {
const content = document.getElementById(`${cardName}-content`); const content = document.getElementById(`${cardName}-content`);
@@ -839,89 +861,139 @@ async function loadRecentPhotos() {
loadRecentPhotos(); loadRecentPhotos();
setInterval(loadRecentPhotos, 30000); setInterval(loadRecentPhotos, 30000);
// Load and display recent call-ins // Load and display recent call-ins.
let showingAllCallins = false; // Source: SFM events (forwarded by series3-watcher from Blastware ACH).
const DEFAULT_CALLINS_DISPLAY = 5; // Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
// is being phased out but kept as a backup.
async function loadRecentCallins() { async function loadRecentCallins() {
const callinsList = document.getElementById('recent-callins-list');
try { try {
const response = await fetch('/api/recent-callins?hours=6'); const response = await fetch('/api/recent-event-callins?limit=10');
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to load recent call-ins'); throw new Error('Failed to load recent call-ins');
} }
const data = await response.json(); const data = await response.json();
const callinsList = document.getElementById('recent-callins-list');
const showAllButton = document.getElementById('show-all-callins');
if (data.call_ins && data.call_ins.length > 0) { if (!data.call_ins || data.call_ins.length === 0) {
// Determine how many to show callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length); return;
const callinsToDisplay = data.call_ins.slice(0, displayCount);
// Build HTML for call-ins list
let html = '';
callinsToDisplay.forEach(callin => {
// Status color
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
// Build location/note line
let subtitle = '';
if (callin.location) {
subtitle = callin.location;
} else if (callin.note) {
subtitle = callin.note;
} }
// Two-column dense grid on lg+, single column below.
let html = '<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-4 gap-y-1">';
data.call_ins.forEach(c => {
const isFalse = c.false_trigger;
const pvs = c.peak_vector_sum;
const pvsStr = (pvs !== null && pvs !== undefined)
? Number(pvs).toFixed(3) + ' in/s'
: '—';
// Subtitle: prefer sensor_location, fallback to project.
const subtitle = c.sensor_location || c.project || '';
// Status dot: amber for false trigger, green for real event,
// gray if unit not in roster.
const dotClass = !c.in_roster
? 'bg-gray-400'
: (isFalse ? 'bg-amber-400' : 'bg-green-500');
// Format event timestamp short (e.g. "05-13 05:00").
let tsShort = '';
if (c.event_timestamp) {
const ts = c.event_timestamp.replace('T', ' ');
// "2026-05-13 05:00:13" → "05-13 05:00"
tsShort = ts.length >= 16 ? ts.slice(5, 16) : ts;
}
const unitLink = c.in_roster
? `<a href="/unit/${c.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">${c.unit_id}</a>`
: `<span class="font-medium text-gray-500 dark:text-gray-400" title="Not in roster">${c.unit_id}</span>`;
html += ` html += `
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0"> <div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
<div class="flex items-center space-x-3"> <div class="flex items-center gap-2 min-w-0 flex-1">
<span class="w-2 h-2 rounded-full ${statusClass}"></span> <span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
<div> <div class="min-w-0 flex-1">
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange"> <div class="flex items-center gap-2">
${callin.unit_id} ${unitLink}
</a> ${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''} <span class="text-xs text-gray-500 dark:text-gray-400">${pvsStr}</span>
</div>
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${subtitle.replace(/"/g, '&quot;')}">${subtitle}</p>` : ''}
</div> </div>
</div> </div>
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span> <div class="text-right ml-2 flex-shrink-0">
<span class="text-xs text-gray-600 dark:text-gray-400 block">${c.time_ago}</span>
${tsShort ? `<span class="text-[10px] text-gray-400 dark:text-gray-500 block font-mono">${tsShort}</span>` : ''}
</div>
</div>`; </div>`;
}); });
html += '</div>';
callinsList.innerHTML = html; callinsList.innerHTML = html;
// Show/hide the "Show all" button
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
showAllButton.classList.remove('hidden');
showAllButton.textContent = showingAllCallins
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
: `Show all (${data.call_ins.length})`;
} else {
showAllButton.classList.add('hidden');
}
} else {
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
showAllButton.classList.add('hidden');
}
} catch (error) { } catch (error) {
console.error('Error loading recent call-ins:', error); console.error('Error loading recent call-ins:', error);
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>'; callinsList.innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
} }
} }
// Toggle show all/show fewer // Load recent call-ins on page load and refresh every 30 seconds.
document.addEventListener('DOMContentLoaded', function() {
const showAllButton = document.getElementById('show-all-callins');
showAllButton.addEventListener('click', function() {
showingAllCallins = !showingAllCallins;
loadRecentCallins();
});
});
// Load recent call-ins on page load and refresh every 30 seconds
loadRecentCallins(); loadRecentCallins();
setInterval(loadRecentCallins, 30000); setInterval(loadRecentCallins, 30000);
// ===== Today's Schedule horizontal card =====
function toggleTodaysSchedule() {
const content = document.getElementById('todays-actions-content');
const chevron = document.getElementById('todays-actions-chevron');
if (!content || !chevron) return;
const isCollapsed = content.classList.toggle('collapsed');
chevron.classList.toggle('collapsed', isCollapsed);
// Remember the user's explicit choice so we don't fight them on the next
// 30s htmx refresh.
localStorage.setItem('todaysScheduleUserToggled', '1');
localStorage.setItem('todaysScheduleCollapsed', isCollapsed ? '1' : '0');
}
function onTodaysActionsSwap(el) {
// Read pending/total counts from the rendered partial to drive
// auto-expand + the header badge.
const badge = document.getElementById('todays-actions-badge');
const content = document.getElementById('todays-actions-content');
const chevron = document.getElementById('todays-actions-chevron');
if (!content || !chevron) return;
// Count yellow status indicators in the rendered partial as a proxy for
// "pending action present today".
const pendingDots = el.querySelectorAll('.bg-yellow-400').length;
const pendingTimes = el.querySelectorAll('.text-yellow-600').length;
const hasPending = pendingDots > 0 || pendingTimes > 0;
if (badge) {
if (hasPending) {
const n = Math.max(pendingDots, pendingTimes);
badge.textContent = `${n} pending today`;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
// Auto-expand only if the user hasn't manually toggled this session AND
// there's something pending. Once the user collapses/expands manually,
// their preference sticks.
const userToggled = localStorage.getItem('todaysScheduleUserToggled') === '1';
if (!userToggled && hasPending) {
content.classList.remove('collapsed');
chevron.classList.remove('collapsed');
} else if (!userToggled && !hasPending) {
content.classList.add('collapsed');
chevron.classList.add('collapsed');
} else if (userToggled) {
const stored = localStorage.getItem('todaysScheduleCollapsed') === '1';
content.classList.toggle('collapsed', stored);
chevron.classList.toggle('collapsed', stored);
}
}
</script> </script>
{% endblock %} {% endblock %}
+8 -1
View File
@@ -36,7 +36,14 @@
</div> </div>
<!-- Age --> <!-- 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 %}"> <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 }} {{ unit.age }}
</span> </span>