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>
This commit is contained in:
2026-05-13 22:58:25 +00:00
parent e15481884a
commit 18fd0472a5
2 changed files with 310 additions and 137 deletions
+101
View File
@@ -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",
}
+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">
<!-- 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="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>
@@ -121,74 +169,6 @@
</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>
<!-- Dashboard Filters -->
@@ -269,6 +249,36 @@
</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 -->
<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')">
@@ -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;
}
</style>
@@ -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;
if (!data.call_ins || data.call_ins.length === 0) {
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
return;
}
// 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 += `
<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 space-x-3">
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
<div>
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
${callin.unit_id}
</a>
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
<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 gap-2 min-w-0 flex-1">
<span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
${unitLink}
${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
<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>
<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>`;
});
html += '</div>';
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) {
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
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);
}
}
</script>
{% endblock %}