diff --git a/backend/static/event-modal.js b/backend/static/event-modal.js new file mode 100644 index 0000000..51d56fd --- /dev/null +++ b/backend/static/event-modal.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + 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 `
+
${_esc(label)}
+
${value}
+ ${options.sub ? `
${options.sub}
` : ''} +
`; + } + + function _sectionHeader(title, sub) { + return `

+ ${_esc(title)}${sub ? ` ${_esc(sub)}` : ''} +

`; + } + + // ── Section renderers ──────────────────────────────────────────── + + function _renderEventHeader(s) { + const ev = s.event || {}; + const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; + return `
+
Serial ${_esc(ev.serial)}
+
Timestamp ${ts}
+
Record Type ${_esc(ev.record_type || '—')}
+
Sample Rate ${ev.sample_rate ?? '—'} sps
+
Rec Time ${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}
+
Waveform Key ${_esc(ev.waveform_key || '—')}
+
`; + } + + 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 `
+
Project ${_esc(p.project || '—')}
+
Client ${_esc(p.client || '—')}
+
Operator ${_esc(p.operator || '—')}
+
Sensor Location ${_esc(p.sensor_location || '—')}
+
+

+ Values are as typed into the seismograph at session start — not the terra-view project/location assignment. +

`; + } + + 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 `
+ ${_kvCard('Transverse', `${_fmt(tran, 4)}`, { sub: 'in/s' })} + ${_kvCard('Vertical', `${_fmt(vert, 4)}`, { sub: 'in/s' })} + ${_kvCard('Longitudinal', `${_fmt(lng, 4)}`, { sub: 'in/s' })} + ${_kvCard('Peak Vector Sum', `${_fmt(pvs, 4)}`, { + sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s', + })} +
`; + } + + 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 `
+ ${_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') : '—')} +
`; + } + + function _sensorRow(label, ch) { + if (!ch) { + return `${_esc(label)} + —`; + } + 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) + ? `${_fmt(ch.amplitude_mv, 1, 'mV')}` + : `${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}`; + + return ` + ${_esc(label)} + ${_fmt(ch.freq_hz, 1, 'Hz')} + ${rightCol} + ${_esc(result)} + `; + } + + function _renderSensorCheck(s) { + const sc = s.bw_report && s.bw_report.sensor_check; + if (!sc) return ''; + return ` + + + + + + + + + + ${_sensorRow('Transverse', sc.tran)} + ${_sensorRow('Vertical', sc.vert)} + ${_sensorRow('Longitudinal', sc.long)} + ${_sensorRow('Microphone', sc.mic)} + +
ChannelFrequencyAmplitude/RatioResult
`; + } + + function _renderDeviceMetadata(s) { + const bw = s.bw_report || {}; + const dev = bw.device || {}; + const rec = bw.recording || {}; + return `
+
Firmware ${_esc(bw.version || '—')}
+
Battery ${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : '—'}
+
Calibrated ${_esc(dev.calibration_date || '—')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}
+
Geo Range ${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : '—'}
+
Stop Mode ${_esc(rec.stop_mode || '—')}
+
Units ${_esc(rec.units || '—')}
+
`; + } + + function _renderFileInfo(s) { + const bw = s.blastware || {}; + const src = s.source || {}; + return `
+
Blastware file ${_esc(bw.filename || '—')} ${bw.filesize ? `(${(bw.filesize / 1024).toFixed(1)} KB)` : ''}
+
SHA-256 ${_esc(bw.sha256 || '—')}
+
Captured at ${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}
+
Tool version ${_esc(src.tool_version || '—')}
+
`; + } + + // ── 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 = ` +
+
+ Loading event detail… +
`; + + 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 = ` +
+ Failed to load event detail: ${_esc(e.message)} +
`; + 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 + ? '' + : `
+ No BW ASCII report paired with this event. + Older events forwarded before the watcher's _ASCII.TXT pairing fix landed lack this data. + PPV is still available from the binary event file. +
`; + + 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(); + }); +})(); diff --git a/templates/partials/event_detail_modal.html b/templates/partials/event_detail_modal.html new file mode 100644 index 0000000..68dc574 --- /dev/null +++ b/templates/partials/event_detail_modal.html @@ -0,0 +1,25 @@ +{# Shared event detail modal. + +Include this partial on any page that wants to call showEventDetail(eventId) +from event-modal.js. The partial provides only the modal shell — the +actual content is rendered by JS into #event-detail-modal-content. + +Usage: + {% include 'partials/event_detail_modal.html' %} + +#} + diff --git a/templates/sfm.html b/templates/sfm.html index 770eacb..68d4558 100644 --- a/templates/sfm.html +++ b/templates/sfm.html @@ -115,21 +115,9 @@ - - +{# Shared event-detail modal — rendered by /static/event-modal.js #} +{% include 'partials/event_detail_modal.html' %} +