feat(sfm): shared event-detail modal with rich BW report fields

Clicking any event row in any of the three event tables (/sfm Events,
project-location Events tab, unit detail SFM Events) now opens a modal
populated from the SFM .sfm.json sidecar.  Previously the /sfm page had
a basic inline modal showing only the columns already in the table;
this rebuilds it as a shared component and exposes the rich fields
that the BW ASCII report unlocks.

Shared component:
- backend/static/event-modal.js — single ~250-line module.  Public API:
  showEventDetail(eventId) fetches /api/sfm/db/events/{id}/sidecar
  live (no extra terra-view caching) and renders sections for:
    • Event (serial, timestamp, record type, sample rate, rec time,
      waveform key)
    • Project Info (operator-typed user notes — project / client /
      operator / sensor_location — flagged in the UI as "as typed
      into the seismograph at session start", not the terra-view
      assignment)
    • Peak Particle Velocity (per-channel + vector sum, with the
      time-of-vector-sum-peak when bw_report is available)
    • Microphone (Peak dB(L) + psi, ZC frequency, time of peak)
    • Sensor Self-Check table (per-channel freq + ratio/amplitude +
      pass/fail)
    • Device & Recording Metadata (firmware, battery, calibration
      date + by-whom, geo range, stop mode, units)
    • Source File (Blastware filename, size, SHA-256, capture time)
  closeEventDetailModal() closes; Escape key also closes.

- templates/partials/event_detail_modal.html — modal shell partial
  (sticky title bar, scrollable body, click-outside-to-close).

Wired into three pages:
- templates/sfm.html: removed the old inline modal + showEventDetail /
  ppvCard / closeEventModal functions (replaced by the shared module).
  Row onclick now passes just the event id instead of the full JSON.
- templates/vibration_location_detail.html: row click on the Events
  tab opens the modal.  The /unit/{serial} link inside the row has
  event.stopPropagation() so the link navigates instead of opening
  the modal.
- templates/unit_detail.html: row click on the SFM Events table opens
  the modal.  The attribution-cell project/location links also got
  stopPropagation.

Graceful degradation: older events forwarded before the watcher's
_ASCII.TXT pairing fix don't have a bw_report block in their sidecar.
The modal renders an amber banner explaining that and shows just the
event + project_info + peak_values + source-file sections.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 03:55:41 +00:00
parent f1f3da8e61
commit 80fa76208a
5 changed files with 342 additions and 72 deletions
+295
View File
@@ -0,0 +1,295 @@
/* event-modal.js — shared event-detail modal.
*
* Used by:
* - /sfm (admin Events tab)
* - /projects/{p}/nrl/{l} (project-location Events tab)
* - /unit/{id} (unit-detail SFM Events table)
*
* Pages must include partials/event_detail_modal.html in the body
* before this script is loaded.
*
* Public API:
* showEventDetail(eventId)
* Open the modal and fetch /api/sfm/db/events/{id}/sidecar to
* populate the rich BW report fields (peaks, ZC freq, sensor
* self-check, device info, etc.) into a tabbed/sectioned view.
*
* closeEventDetailModal()
* Close the modal.
*
* Notes:
* - Fetches sidecar live from SFM via terra-view's /api/sfm proxy.
* - Renders gracefully when the sidecar lacks a bw_report block
* (older events forwarded before the _ASCII.TXT pairing fix).
* - All functions are global on window so inline onclick handlers
* can reach them across all three host pages.
*/
(function () {
const MODAL_ID = 'event-detail-modal';
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _fmt(v, digits = 4, suffix = '') {
if (v == null || (typeof v === 'number' && Number.isNaN(v))) return '—';
if (typeof v === 'number') {
return v.toFixed(digits) + (suffix ? ` ${suffix}` : '');
}
return _esc(v) + (suffix ? ` ${suffix}` : '');
}
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 _kvCard(label, value, options = {}) {
// Single key-value tile. `value` is pre-rendered HTML (or text).
const colorCls = options.colorCls || '';
const valCls = `font-mono font-semibold ${colorCls}`;
return `<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">${_esc(label)}</div>
<div class="${valCls} mt-1">${value}</div>
${options.sub ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${options.sub}</div>` : ''}
</div>`;
}
function _sectionHeader(title, sub) {
return `<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-5 first:mt-0">
${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
</h4>`;
}
// ── Section renderers ────────────────────────────────────────────
function _renderEventHeader(s) {
const ev = s.event || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Serial</span> <span class="font-mono font-semibold text-seismo-orange ml-1">${_esc(ev.serial)}</span></div>
<div><span class="text-gray-500">Timestamp</span> <span class="font-medium ml-1">${ts}</span></div>
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(ev.record_type || '—')}</span></div>
<div><span class="text-gray-500">Sample Rate</span> <span class="font-medium ml-1">${ev.sample_rate ?? '—'} sps</span></div>
<div><span class="text-gray-500">Rec Time</span> <span class="font-medium ml-1">${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}</span></div>
<div><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '—')}</span></div>
</div>`;
}
function _renderProjectInfo(s) {
// The "user notes" metadata the operator typed into the BW device.
// These are the strings the future metadata-driven parser will use.
const p = s.project_info || {};
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '—')}</span></div>
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
<div><span class="text-gray-500">Operator</span> <span class="font-medium ml-1">${_esc(p.operator || '—')}</span></div>
<div><span class="text-gray-500">Sensor Location</span> <span class="font-medium ml-1">${_esc(p.sensor_location || '—')}</span></div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
Values are as typed into the seismograph at session start — not the terra-view project/location assignment.
</p>`;
}
function _renderPeakValues(s) {
// Prefer bw_report.peaks for richer per-channel data; fall back to peak_values.
const bwPeaks = (s.bw_report && s.bw_report.peaks) || null;
const pv = s.peak_values || {};
const tran = bwPeaks ? bwPeaks.tran?.ppv_ips : pv.transverse;
const vert = bwPeaks ? bwPeaks.vert?.ppv_ips : pv.vertical;
const lng = bwPeaks ? bwPeaks.long?.ppv_ips : pv.longitudinal;
const pvs = bwPeaks ? bwPeaks.vector_sum?.ips : pv.vector_sum;
const pvsAt = bwPeaks ? bwPeaks.vector_sum?.time_s : null;
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
${_kvCard('Transverse', `<span class="${_ppvClass(tran)}">${_fmt(tran, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Vertical', `<span class="${_ppvClass(vert)}">${_fmt(vert, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Longitudinal', `<span class="${_ppvClass(lng)}">${_fmt(lng, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Peak Vector Sum', `<span class="${_ppvClass(pvs)} text-base">${_fmt(pvs, 4)}</span>`, {
sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s',
})}
</div>`;
}
function _renderMic(s) {
const mic = (s.bw_report && s.bw_report.mic) || null;
const pv = s.peak_values || {};
if (!mic && pv.mic_psi == null) return '';
const dbl = mic?.pspl_dbl;
const psi = pv.mic_psi;
const zcHz = mic?.zc_freq_hz;
const tPk = mic?.time_of_peak_s;
const wt = mic?.weighting;
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
${_kvCard('Peak Mic psi', _fmt(psi, 4))}
${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
</div>`;
}
function _sensorRow(label, ch) {
if (!ch) {
return `<tr><td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
<td class="px-3 py-2 text-sm text-gray-400" colspan="3">—</td></tr>`;
}
const result = ch.result || '—';
const resultCls = result === 'Passed'
? 'text-green-600 dark:text-green-400'
: (result === 'Failed' ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-500');
// Geo channels have freq + ratio; mic has freq + amplitude.
const rightCol = (ch.amplitude_mv != null)
? `<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.amplitude_mv, 1, 'mV')}</td>`
: `<td class="px-3 py-2 text-sm font-mono">${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}</td>`;
return `<tr>
<td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.freq_hz, 1, 'Hz')}</td>
${rightCol}
<td class="px-3 py-2 text-sm ${resultCls}">${_esc(result)}</td>
</tr>`;
}
function _renderSensorCheck(s) {
const sc = s.bw_report && s.bw_report.sensor_check;
if (!sc) return '';
return `<table class="w-full text-left rounded overflow-hidden border border-gray-200 dark:border-gray-700">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Channel</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Frequency</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Amplitude/Ratio</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Result</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-slate-800">
${_sensorRow('Transverse', sc.tran)}
${_sensorRow('Vertical', sc.vert)}
${_sensorRow('Longitudinal', sc.long)}
${_sensorRow('Microphone', sc.mic)}
</tbody>
</table>`;
}
function _renderDeviceMetadata(s) {
const bw = s.bw_report || {};
const dev = bw.device || {};
const rec = bw.recording || {};
return `<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Firmware</span> <span class="font-mono text-xs ml-1">${_esc(bw.version || '—')}</span></div>
<div><span class="text-gray-500">Battery</span> <span class="font-medium ml-1">${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : '—'}</span></div>
<div><span class="text-gray-500">Calibrated</span> <span class="font-medium ml-1">${_esc(dev.calibration_date || '—')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}</span></div>
<div><span class="text-gray-500">Geo Range</span> <span class="font-medium ml-1">${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : '—'}</span></div>
<div><span class="text-gray-500">Stop Mode</span> <span class="font-medium ml-1">${_esc(rec.stop_mode || '—')}</span></div>
<div><span class="text-gray-500">Units</span> <span class="font-medium ml-1">${_esc(rec.units || '—')}</span></div>
</div>`;
}
function _renderFileInfo(s) {
const bw = s.blastware || {};
const src = s.source || {};
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${bw.filesize ? `<span class="text-xs text-gray-500 ml-2">(${(bw.filesize / 1024).toFixed(1)} KB)</span>` : ''}</div>
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span></div>
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
</div>`;
}
// ── Public API ───────────────────────────────────────────────────
window.showEventDetail = async function (eventId) {
const modal = document.getElementById(MODAL_ID);
if (!modal) {
console.warn('event-modal: include event_detail_modal.html partial on this page.');
return;
}
modal.classList.remove('hidden');
document.getElementById(MODAL_ID + '-title').textContent = 'Event Detail';
document.getElementById(MODAL_ID + '-content').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 event detail…
</div>`;
let s;
try {
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`);
if (!r.ok) {
throw new Error('HTTP ' + r.status + ' fetching sidecar');
}
s = await r.json();
} catch (e) {
document.getElementById(MODAL_ID + '-content').innerHTML = `
<div class="text-center py-8 text-red-500 text-sm">
Failed to load event detail: ${_esc(e.message)}
</div>`;
return;
}
const ev = s.event || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '';
document.getElementById(MODAL_ID + '-title').textContent =
`Event — ${ev.serial || '?'} @ ${ts}`;
const hasReport = !!s.bw_report;
const reportNote = hasReport
? ''
: `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-300 mb-4">
<strong>No BW ASCII report paired with this event.</strong>
Older events forwarded before the watcher's <code class="font-mono text-xs">_ASCII.TXT</code> pairing fix landed lack this data.
PPV is still available from the binary event file.
</div>`;
document.getElementById(MODAL_ID + '-content').innerHTML = `
${reportNote}
${_sectionHeader('Event')}
${_renderEventHeader(s)}
${_sectionHeader('Project Info', '(operator-typed at session start)')}
${_renderProjectInfo(s)}
${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)}
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
${_sectionHeader('Microphone')}
${_renderMic(s)}
` : ''}
${hasReport ? `
${_sectionHeader('Sensor Self-Check')}
${_renderSensorCheck(s)}
${_sectionHeader('Device & Recording Metadata')}
${_renderDeviceMetadata(s)}
` : ''}
${_sectionHeader('Source File')}
${_renderFileInfo(s)}
`;
};
window.closeEventDetailModal = function () {
const modal = document.getElementById(MODAL_ID);
if (modal) modal.classList.add('hidden');
};
// Close on Escape.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.closeEventDetailModal();
});
})();