feat(sfm): per-unit event history with attribution + Unattributed bucket

Phase 2 of the SFM integration.  Adds a "SFM Events" section to the
seismograph unit detail page (/unit/{id}).  Every event SFM has for the
serial is shown, with each event annotated by which project/location
assignment window it falls into.  Events outside every assignment window
get the "⚠ Unattributed" badge plus a "<N>d before/after <nearest location>"
hint — that's the operator's signal that backdating an assignment (Phase 1
edit-pencil) will absorb the orphan events.

Backend:
- backend/services/sfm_events.py: new events_for_unit() helper.  Fetches
  all events for the serial via SFM /db/events (one call, ceiling 5000),
  loads every UnitAssignment for the unit + resolves MonitoringLocation +
  Project names, then annotates each event with attribution or
  nearest_assignment (signed delta_days).  Bucket filter: all /
  attributed / unattributed.  Stats always reflect the full event set so
  the "Unattributed" KPI tile is meaningful regardless of which bucket
  is being viewed.

- backend/routers/units.py: new GET /api/units/{unit_id}/events with
  bucket / date-range / false_trigger / limit query params.  404s on
  unknown unit_id; returns an empty payload for non-seismograph
  device_types so the page can render the section conditionally.

Frontend (templates/unit_detail.html):
- New "SFM Events" section between "Deployment History" and "Timeline",
  styled to match the existing card pattern (border-t divider, same
  heading weight).
- Hidden by default; revealed only when currentUnit.device_type ===
  'seismograph' after the unit data loads.
- Four KPI tiles: Total Events / Unattributed (highlighted amber when
  > 0) / Peak PVS / Last Event.
- Filters: Bucket (all|attributed|unattributed), From/To, False
  Triggers, Limit, + Refresh.
- Event table with Attribution column.  Attributed rows link to the
  project/location detail page; unattributed rows are tinted amber
  and show "<N>d before/after <nearest location>" with a link to the
  nearest location.
- Empty-state copy varies by bucket: e.g. unattributed-with-zero shows
  " All events for this unit are attributed to a project/location".

Verified end-to-end against BE11529 (81 events total, 24 attributed,
57 unattributed — all 57 unattributed events emitted within hours of
the assignment start, which means backdating the assignment by a day
would attribute every one of them).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:38:46 +00:00
parent 09db988a35
commit bc5a151faa
3 changed files with 492 additions and 3 deletions
+263
View File
@@ -294,6 +294,91 @@
</div>
</div>
<!-- SFM Events (seismographs only) -->
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">SFM Events</h3>
<button onclick="loadUnitEvents()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Refresh
</button>
</div>
<!-- KPI tiles -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
<span id="ue-stat-total" class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Unattributed</span>
<span id="ue-stat-unattr" class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">outside any assignment window</span>
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
<span id="ue-stat-peak" class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
<span id="ue-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></span>
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
<span id="ue-stat-last" class="text-base font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-end gap-3 mb-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Bucket</label>
<select id="ue-filter-bucket" onchange="loadUnitEvents()"
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">All Events</option>
<option value="attributed">Attributed Only</option>
<option value="unattributed">Unattributed Only</option>
</select>
</div>
<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="ue-filter-from" onchange="loadUnitEvents()"
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="ue-filter-to" onchange="loadUnitEvents()"
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="ue-filter-ft" onchange="loadUnitEvents()"
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</option>
<option value="false">Real Only</option>
<option value="true">FT 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="ue-filter-limit" onchange="loadUnitEvents()"
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="clearUnitEventFilters()"
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>
</div>
<!-- Event table -->
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
Loading events…
</div>
</div>
</div>
<!-- Unit History Timeline -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
@@ -1886,8 +1971,186 @@ loadUnitData().then(() => {
loadPhotos();
loadUnitHistory();
loadDeploymentHistory();
if (currentUnit && currentUnit.device_type === 'seismograph') {
document.getElementById('sfmEventsSection').classList.remove('hidden');
loadUnitEvents();
}
});
// ── SFM Events section ──────────────────────────────────────────────────────
function clearUnitEventFilters() {
document.getElementById('ue-filter-bucket').value = 'all';
document.getElementById('ue-filter-from').value = '';
document.getElementById('ue-filter-to').value = '';
document.getElementById('ue-filter-ft').value = '';
document.getElementById('ue-filter-limit').value = '500';
loadUnitEvents();
}
async function loadUnitEvents() {
if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
const container = document.getElementById('ue-events-container');
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>';
const params = new URLSearchParams();
const bucket = document.getElementById('ue-filter-bucket').value;
const from = document.getElementById('ue-filter-from').value;
const to = document.getElementById('ue-filter-to').value;
const ft = document.getElementById('ue-filter-ft').value;
const limit = document.getElementById('ue-filter-limit').value;
params.set('bucket', bucket);
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/units/${currentUnit.id}/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();
renderUnitEventStats(d.stats);
renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total);
} catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
}
}
function renderUnitEventStats(stats) {
const s = stats || {};
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
const unattrEl = document.getElementById('ue-stat-unattr');
unattrEl.textContent = (s.unattributed_count ?? 0).toLocaleString();
// Highlight unattributed in amber/red if non-zero — visual signal that
// the operator has assignment-window cleanup to do.
unattrEl.className = 'text-2xl font-bold mt-1 ' + (
(s.unattributed_count ?? 0) > 0
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-900 dark:text-white'
);
if (s.peak_pvs != null) {
document.getElementById('ue-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
document.getElementById('ue-stat-peak-when').textContent = when || '—';
} else {
document.getElementById('ue-stat-peak').textContent = '—';
document.getElementById('ue-stat-peak-when').textContent = '—';
}
if (s.last_event) {
const dt = s.last_event.slice(0, 19).replace('T', ' ');
document.getElementById('ue-stat-last').textContent = dt;
} else {
document.getElementById('ue-stat-last').textContent = '—';
}
}
function _ueFmtPPV(v) {
if (v == null) return '—';
return v.toFixed(4);
}
function _uePpvClass(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 _ueEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _ueAttrCell(ev) {
const a = ev.attribution;
if (a) {
// Attributed: project / location link.
const projLabel = _ueEsc(a.project_name || '—');
const locLabel = _ueEsc(a.location_name || '—');
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
class="text-seismo-orange hover:text-seismo-navy"
title="${projLabel}${locLabel}">
📍 ${locLabel}
</a>
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
}
// Unattributed: show nearest assignment + delta for context.
const n = ev.nearest_assignment;
if (n) {
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
const days = Math.abs(n.delta_days);
const daysLabel = days < 1
? `<${(days * 24).toFixed(1)}h`
: `${days.toFixed(1)}d`;
return `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
</div>`;
}
return `<span class="px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">⚠ No assignments</span>`;
}
function renderUnitEventTable(events, total, container, bucket, assignmentsTotal) {
if (!events || events.length === 0) {
let msg;
if (bucket === 'unattributed') {
msg = assignmentsTotal === 0
? 'No assignments yet — every event from this unit is unattributed. Assign it to a project location to start attributing events.'
: '✅ All events for this unit are attributed to a project/location.';
} else if (bucket === 'attributed') {
msg = assignmentsTotal === 0
? 'No assignments yet for this unit.'
: 'No events recorded inside any assignment window with the current filter.';
} else {
msg = 'No events found for this unit with the current filter.';
}
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 = _ueFmtPPV(ev.tran_ppv);
const vert = _ueFmtPPV(ev.vert_ppv);
const lng = _ueFmtPPV(ev.long_ppv);
const pvs = _ueFmtPPV(ev.peak_vector_sum);
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 ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}">
<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 ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.long_ppv)}">${lng}</td>
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_uePpvClass(ev.peak_vector_sum)}">${pvs}</td>
<td class="px-4 py-2.5 text-sm">${ft}</td>
<td class="px-4 py-2.5 text-sm">${_ueAttrCell(ev)}</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3 pb-1">Showing ${events.length} of ${total.toLocaleString()} event${total === 1 ? '' : 's'}</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">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">Flags</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
}
// ===== Pair Device Modal Functions =====
let pairModalModems = []; // Cache loaded modems
let pairModalDeviceType = ''; // Current device type