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
+209 -137
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;
}
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>
</div>
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
</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');
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-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>
<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;
} 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 %}