From 18fd0472a517b17b7d4f7a528b3afb27f1fe6a8c Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 13 May 2026 22:58:25 +0000 Subject: [PATCH] feat(dashboard): reorder top row, move schedule below map, source call-ins from SFM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/routers/activity.py | 101 +++++++++++ templates/dashboard.html | 346 ++++++++++++++++++++++-------------- 2 files changed, 310 insertions(+), 137 deletions(-) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index b881a8e..02c6813 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -4,11 +4,29 @@ from sqlalchemy import desc from pathlib import Path from datetime import datetime, timedelta, timezone from typing import List, Dict, Any +import os +import logging +import httpx from backend.database import get_db from backend.models import UnitHistory, Emitter, RosterUnit +log = logging.getLogger(__name__) + 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") @@ -144,3 +162,86 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends( "hours": hours, "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", + } diff --git a/templates/dashboard.html b/templates/dashboard.html index d1ec900..8b8e22c 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -29,7 +29,55 @@
- + +
+
+

Recent Alerts

+
+ + + + + + + +
+
+
+

Loading alerts...

+
+
+ + +
+
+
+

Recent Call-Ins

+ +
+
+ + + + + + + +
+
+
+
+

Loading recent call-ins...

+
+ + View all events → + +
+
+ +

Fleet Summary

@@ -121,74 +169,6 @@
- -
-
-

Recent Alerts

-
- - - - - - - -
-
-
-

Loading alerts...

-
-
- - -
-
-

Recent Call-Ins

-
- - - - - - - -
-
-
-
-

Loading recent call-ins...

-
- -
-
- - -
-
-

Today's Schedule

-
- - - - - - - -
-
-
-

Loading scheduled actions...

-
-
-
@@ -269,6 +249,36 @@ + +
+
+
+ + + +

Today's Schedule

+ +
+ +
+ +
+
@@ -364,6 +374,17 @@ 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; +} @@ -654,7 +675,8 @@ function toggleCard(cardName) { // Restore card states from localStorage on page load function restoreCardStates() { 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 => { const content = document.getElementById(`${cardName}-content`); @@ -839,89 +861,139 @@ async function loadRecentPhotos() { loadRecentPhotos(); setInterval(loadRecentPhotos, 30000); -// Load and display recent call-ins -let showingAllCallins = false; -const DEFAULT_CALLINS_DISPLAY = 5; - +// Load and display recent call-ins. +// Source: SFM events (forwarded by series3-watcher from Blastware ACH). +// Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins +// is being phased out but kept as a backup. async function loadRecentCallins() { + const callinsList = document.getElementById('recent-callins-list'); try { - const response = await fetch('/api/recent-callins?hours=6'); + const response = await fetch('/api/recent-event-callins?limit=10'); if (!response.ok) { throw new Error('Failed to load recent call-ins'); } 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) { - // Determine how many to show - const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length); - 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; - } - - html += ` -
-
- -
- - ${callin.unit_id} - - ${subtitle ? `

${subtitle}

` : ''} -
-
- ${callin.time_ago} -
`; - }); - - 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 = '

No units have called in within the past 6 hours

'; - showAllButton.classList.add('hidden'); + if (!data.call_ins || data.call_ins.length === 0) { + callinsList.innerHTML = '

No recent event call-ins from SFM

'; + return; } + + // Two-column dense grid on lg+, single column below. + let html = '
'; + 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 + ? `${c.unit_id}` + : `${c.unit_id}`; + + html += ` +
+
+ +
+
+ ${unitLink} + ${isFalse ? 'false' : ''} + ${pvsStr} +
+ ${subtitle ? `

${subtitle}

` : ''} +
+
+
+ ${c.time_ago} + ${tsShort ? `${tsShort}` : ''} +
+
`; + }); + html += '
'; + callinsList.innerHTML = html; } catch (error) { console.error('Error loading recent call-ins:', error); - document.getElementById('recent-callins-list').innerHTML = '

Failed to load recent call-ins

'; + callinsList.innerHTML = '

Failed to load recent call-ins

'; } } -// Toggle show all/show fewer -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 +// Load recent call-ins on page load and refresh every 30 seconds. loadRecentCallins(); 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); + } +} {% endblock %}