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 @@
Loading alerts...
+Loading recent call-ins...
+Loading alerts...
-Loading recent call-ins...
-Loading scheduled actions...
-Loading scheduled actions...
+${subtitle}
` : ''} -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 = '${subtitle}
` : ''} +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 %}