feat(sfm): wire SFM events into project-location detail page

Phase 1 of the SFM project/location integration. When viewing a vibration
monitoring location, operators now see the events that were actually
recorded there — fanned out across every seismograph that was ever
assigned to that location (handles mid-project unit swaps).

Backend:
- backend/services/sfm_events.py: new events_for_location() async helper.
  Walks UnitAssignment rows for the location (active + closed), intersects
  each assignment's [assigned_at, assigned_until] window with the requested
  filter, and concurrently queries SFM /db/events for each (serial, window)
  pair via httpx.AsyncClient.  Unions, sorts newest-first, computes summary
  stats (event count, peak PVS + when/who, last event, false-trigger count)
  over the full set, and trims to the user's display limit.  Over-fetches
  per-window (up to 5000) so stats stay accurate even with a small display
  limit.

- backend/routers/project_locations.py: new GET endpoint
  /api/projects/{project_id}/locations/{location_id}/events.  Validates
  project/location pairing (404 on mismatch).  SLM locations return an
  empty payload rather than 404 so the frontend can render gracefully.

Frontend:
- templates/vibration_location_detail.html: new "Events" tab on the
  location detail page.  KPI tiles (total / peak PVS / last event / false
  triggers), "Seismographs deployed at this location" assignment list
  (transparency: shows each assignment's date range and contributed event
  count), date / false-trigger / limit filters, and the paginated event
  table.  Lazy-loaded on first tab visit; manual refresh button.

Architectural notes:
- SFM remains the single source of truth for events.  No event sync; live
  HTTP per page load.
- UnitAssignment is the join key (not MonitoringSession).
- Events whose timestamp falls outside every assignment window are NOT
  surfaced here.  Those orphan events get a dedicated "Unattributed
  events" view on the per-unit detail page in Phase 2.

Out of scope (this commit):
- Phase 2 (per-unit history view) and Phase 3 (project-level roll-up)
  reuse this helper but ship separately.
- Phase 4 (deprecating deployment_records) is independent.
- Extracting the event-table JS to a shared file is a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:57:14 +00:00
parent a71e6f5efd
commit df771a87de
3 changed files with 623 additions and 0 deletions
+260
View File
@@ -65,6 +65,11 @@
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
Overview
</button>
<button onclick="switchTab('events')"
data-tab="events"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Events
</button>
<button onclick="switchTab('settings')"
data-tab="settings"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
@@ -185,6 +190,92 @@
</div>
</div>
<!-- Events Tab -->
<div id="events-tab" class="tab-panel hidden">
<!-- Summary stats -->
<div id="events-stats" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
<span id="ev-stat-count" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
<span id="ev-stat-peak" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
<span id="ev-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></span>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
<span id="ev-stat-last" class="text-lg font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
<span id="ev-stat-ft" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
</div>
<!-- Assignments used (transparency: which seismographs contributed events) -->
<div id="events-assignments-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 hidden">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Seismographs deployed at this location</h3>
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6">
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="datetime-local" id="ev-filter-from" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="datetime-local" id="ev-filter-to" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
<select id="ev-filter-ft" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">All Events</option>
<option value="false">Real Events Only</option>
<option value="true">False Triggers Only</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
<select id="ev-filter-limit" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="100">100</option>
<option value="250">250</option>
<option value="500" selected>500</option>
<option value="1000">1000</option>
</select>
</div>
<button onclick="clearEventFilters()"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear
</button>
<button onclick="loadLocationEvents()"
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy">
↻ Refresh
</button>
</div>
</div>
<!-- Event table -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
<div id="events-container" class="overflow-x-auto">
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
Loading events…
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
@@ -324,6 +415,175 @@ function switchTab(tabName) {
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
button.classList.add('border-seismo-orange', 'text-seismo-orange');
}
// Lazy-load Events tab on first visit (or whenever it's reopened).
if (tabName === 'events' && !_eventsLoaded) {
loadLocationEvents();
}
}
// ── Events tab ───────────────────────────────────────────────────────────────
let _eventsLoaded = false;
function clearEventFilters() {
document.getElementById('ev-filter-from').value = '';
document.getElementById('ev-filter-to').value = '';
document.getElementById('ev-filter-ft').value = '';
document.getElementById('ev-filter-limit').value = '500';
loadLocationEvents();
}
async function loadLocationEvents() {
const container = document.getElementById('events-container');
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
const params = new URLSearchParams();
const from = document.getElementById('ev-filter-from').value;
const to = document.getElementById('ev-filter-to').value;
const ft = document.getElementById('ev-filter-ft').value;
const limit = document.getElementById('ev-filter-limit').value;
if (from) params.set('from_dt', from.replace('T', ' '));
if (to) params.set('to_dt', to.replace('T', ' '));
if (ft) params.set('false_trigger', ft);
params.set('limit', limit);
try {
const r = await fetch(`/api/projects/${projectId}/locations/${locationId}/events?${params.toString()}`);
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
const d = await r.json();
_eventsLoaded = true;
renderEventStats(d.stats);
renderAssignmentsUsed(d.assignments_used);
renderEventTable(d.events, d.count, container);
} catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
}
}
function renderEventStats(stats) {
const s = stats || {};
document.getElementById('ev-stat-count').textContent = (s.event_count ?? 0).toLocaleString();
document.getElementById('ev-stat-ft').textContent = (s.false_trigger_count ?? 0).toLocaleString();
if (s.peak_pvs != null) {
document.getElementById('ev-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
const who = s.peak_pvs_serial || '';
document.getElementById('ev-stat-peak-when').textContent = [when, who].filter(Boolean).join(' · ') || '—';
} else {
document.getElementById('ev-stat-peak').textContent = '—';
document.getElementById('ev-stat-peak-when').textContent = '—';
}
if (s.last_event) {
const dt = s.last_event.slice(0, 19).replace('T', ' ');
document.getElementById('ev-stat-last').textContent = dt;
} else {
document.getElementById('ev-stat-last').textContent = '—';
}
}
function renderAssignmentsUsed(assignments) {
const card = document.getElementById('events-assignments-card');
const listEl = document.getElementById('ev-assignments-list');
const countEl = document.getElementById('ev-assignments-count');
if (!assignments || assignments.length === 0) {
card.classList.add('hidden');
return;
}
card.classList.remove('hidden');
countEl.textContent = `${assignments.length} assignment${assignments.length === 1 ? '' : 's'}`;
listEl.innerHTML = assignments.map(a => {
const start = a.assigned_at ? a.assigned_at.slice(0, 10) : '?';
const end = a.assigned_until ? a.assigned_until.slice(0, 10) : 'present';
const isActive = !a.assigned_until;
const badge = isActive
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: '';
return `<div class="py-2 flex items-center justify-between">
<div>
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
${badge}
<span class="ml-3 text-sm text-gray-600 dark:text-gray-400">${start}${end}</span>
</div>
<span class="text-sm text-gray-700 dark:text-gray-300">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
</div>`;
}).join('');
}
function renderEventTable(events, total, container) {
if (!events || events.length === 0) {
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');
const msg = haveAssignments
? 'No events recorded for the assignments above within the current filter.'
: 'No seismographs have been assigned to this location yet. Assign one to start collecting events.';
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
return;
}
const rows = events.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = fmtPPV(ev.tran_ppv);
const vert = fmtPPV(ev.vert_ppv);
const lng = fmtPPV(ev.long_ppv);
const pvs = fmtPPV(ev.peak_vector_sum);
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
const ft = ev.false_trigger
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
: '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
<td class="px-4 py-2.5 text-sm font-mono font-medium text-seismo-orange">
<a href="/unit/${esc(ev.serial)}" class="hover:text-seismo-navy">${esc(ev.serial)}</a>
</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.tran_ppv)}">${tran}</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.vert_ppv)}">${vert}</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
<td class="px-4 py-2.5 text-sm">${ft}</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3">Showing ${events.length} of ${total.toLocaleString()} events</div>
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
}
function fmtPPV(v) {
if (v == null) return '—';
return v.toFixed(4);
}
function ppvClass(v) {
if (v == null) return 'text-gray-400';
if (v < 0.5) return 'text-green-600 dark:text-green-400';
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
return 'text-red-600 dark:text-red-400 font-semibold';
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Location settings form submission