diff --git a/backend/static/event-modal.js b/backend/static/event-modal.js index 28a97d8..cf32f7f 100644 --- a/backend/static/event-modal.js +++ b/backend/static/event-modal.js @@ -62,6 +62,27 @@ `; } + function _deriveRecordType(filename, fallback) { + // SFM currently hardcodes record_type="Waveform" for every event. + // The actual type is encoded in the LAST character of the Blastware + // filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram). + // We derive it client-side until SFM is fixed; if the suffix isn't + // a known code we fall back to whatever SFM reported. + if (!filename) return fallback || '—'; + const dotIdx = filename.lastIndexOf('.'); + if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—'; + const ext = filename.slice(dotIdx + 1); + const lastChar = ext.slice(-1).toUpperCase(); + const typeMap = { + 'H': 'Histogram', + 'W': 'Waveform', + 'M': 'Manual', + 'E': 'Event', + 'C': 'Combo', + }; + return typeMap[lastChar] || (fallback || '—'); + } + function _sectionHeader(title, sub) { return `

${_esc(title)}${sub ? ` ${_esc(sub)}` : ''} @@ -72,21 +93,23 @@ function _renderEventHeader(s) { const ev = s.event || {}; + const bw = s.blastware || {}; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; + const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type); return `
Serial ${_esc(ev.serial)}
Timestamp ${ts}
-
Record Type ${_esc(ev.record_type || '—')}
+
Record Type ${_esc(recType)}
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) { + function _renderUserNotes(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 || {}; + const p = s.user_notes || {}; return `
Project ${_esc(p.project || '—')}
Client ${_esc(p.client || '—')}
@@ -120,20 +143,21 @@ } function _renderMic(s) { + // Operators only care about dB(L); PSI tile was dropped 2026-05. + // We still render the row if any mic data is present so ZC freq / + // time-of-peak stay visible even when bw_report.mic is missing. 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 `
+ 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') : '—')}
`; @@ -223,6 +247,14 @@ Blastware file unavailable `} + @@ -232,6 +264,16 @@ Download sidecar JSON
+ `; return `${downloadButtons} @@ -294,8 +336,8 @@ ${_sectionHeader('Event')} ${_renderEventHeader(s)} - ${_sectionHeader('Project Info', '(operator-typed at session start)')} - ${_renderProjectInfo(s)} + ${_sectionHeader('User Notes')} + ${_renderUserNotes(s)} ${_sectionHeader('Peak Particle Velocity')} ${_renderPeakValues(s)} @@ -323,6 +365,32 @@ if (modal) modal.classList.add('hidden'); }; + window.toggleEventJsonViewer = function () { + const viewer = document.getElementById('event-json-viewer'); + const label = document.getElementById('event-json-toggle-label'); + if (!viewer) return; + const isHidden = viewer.classList.toggle('hidden'); + if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON'; + }; + + window.copyEventJson = function () { + const pre = document.getElementById('event-json-pre'); + const label = document.getElementById('event-json-copy-label'); + if (!pre) return; + navigator.clipboard.writeText(pre.textContent).then(() => { + if (label) { + label.textContent = 'Copied!'; + setTimeout(() => { label.textContent = 'Copy'; }, 1500); + } + }).catch(err => { + console.error('clipboard write failed', err); + if (label) { + label.textContent = 'Failed'; + setTimeout(() => { label.textContent = 'Copy'; }, 1500); + } + }); + }; + // Close on Escape. document.addEventListener('keydown', function (e) { if (e.key === 'Escape') window.closeEventDetailModal(); diff --git a/templates/sfm.html b/templates/sfm.html index a9123ee..bbb94e2 100644 --- a/templates/sfm.html +++ b/templates/sfm.html @@ -220,6 +220,12 @@ async function loadStats() { } // ── Events tab ─────────────────────────────────────────────────────────────── +// Module-level cache so sort can re-render without re-fetching. +let _eventsCache = []; +let _eventsTotal = 0; +let _eventsSortKey = 'timestamp'; +let _eventsSortDir = 'desc'; // 'asc' | 'desc' + async function loadEvents() { const container = document.getElementById('events-container'); container.innerHTML = '
Loading events…
'; @@ -241,19 +247,61 @@ async function loadEvents() { const r = await fetch('/api/sfm/db/events?' + params.toString()); if (!r.ok) { throw new Error('HTTP ' + r.status); } const d = await r.json(); - renderEventsTable(d.events, d.count, container); + _eventsCache = d.events || []; + _eventsTotal = d.count || 0; + renderEventsTable(_eventsCache, _eventsTotal, container); } catch (e) { container.innerHTML = `
Failed to load events: ${e.message}
`; } } +function sortEvents(key) { + // Toggle direction if same column clicked; otherwise default to desc. + if (_eventsSortKey === key) { + _eventsSortDir = _eventsSortDir === 'desc' ? 'asc' : 'desc'; + } else { + _eventsSortKey = key; + _eventsSortDir = 'desc'; + } + renderEventsTable(_eventsCache, _eventsTotal, document.getElementById('events-container')); +} + +function _applySort(events) { + const key = _eventsSortKey; + const dir = _eventsSortDir === 'asc' ? 1 : -1; + return [...events].sort((a, b) => { + let av = a[key], bv = b[key]; + // Nulls always sort last regardless of dir. + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir; + return String(av).localeCompare(String(bv)) * dir; + }); +} + +function _sortIndicator(key) { + if (_eventsSortKey !== key) return ''; + return _eventsSortDir === 'desc' + ? '' + : ''; +} + +function _sortableTh(label, key) { + return ` + ${label}${_sortIndicator(key)} + `; +} + function renderEventsTable(events, total, container) { if (!events || events.length === 0) { container.innerHTML = '

No events found matching the current filters.

'; return; } - const rows = events.map(ev => { + const sorted = _applySort(events); + const rows = sorted.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); @@ -288,15 +336,15 @@ function renderEventsTable(events, total, container) { - - - - - - - - - + ${_sortableTh('Timestamp', 'timestamp')} + ${_sortableTh('Serial', 'serial')} + ${_sortableTh('Project', 'project')} + ${_sortableTh('Tran', 'tran_ppv')} + ${_sortableTh('Vert', 'vert_ppv')} + ${_sortableTh('Long', 'long_ppv')} + ${_sortableTh('PVS', 'peak_vector_sum')} + ${_sortableTh('Mic', 'mic_ppv')} + ${_sortableTh('Flags', 'false_trigger')} ${rows} diff --git a/templates/unit_detail.html b/templates/unit_detail.html index aa39df4..8821781 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -2142,6 +2142,15 @@ function clearUnitEventFilters() { loadUnitEvents(); } +// Module-level state for the unit-events table sort. Cache lets us re-sort +// without a refetch when the user clicks a column header. +let _ueEventsCache = []; +let _ueEventsTotal = 0; +let _ueEventsBucket = 'all'; +let _ueAssignmentsTotal = 0; +let _ueSortKey = 'timestamp'; +let _ueSortDir = 'desc'; + async function loadUnitEvents() { if (!currentUnit || currentUnit.device_type !== 'seismograph') return; const container = document.getElementById('ue-events-container'); @@ -2166,13 +2175,62 @@ async function loadUnitEvents() { throw new Error(err.detail || 'HTTP ' + r.status); } const d = await r.json(); + _ueEventsCache = d.events || []; + _ueEventsTotal = d.count || 0; + _ueEventsBucket = bucket; + _ueAssignmentsTotal = d.assignments_total || 0; renderUnitEventStats(d.stats); - renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total); + renderUnitEventTable(_ueEventsCache, _ueEventsTotal, container, bucket, _ueAssignmentsTotal); } catch (e) { container.innerHTML = `
Failed to load events: ${e.message}
`; } } +function sortUnitEvents(key) { + if (_ueSortKey === key) { + _ueSortDir = _ueSortDir === 'desc' ? 'asc' : 'desc'; + } else { + _ueSortKey = key; + _ueSortDir = 'desc'; + } + renderUnitEventTable(_ueEventsCache, _ueEventsTotal, + document.getElementById('ue-events-container'), _ueEventsBucket, _ueAssignmentsTotal); +} + +function _ueApplySort(events) { + const key = _ueSortKey; + const dir = _ueSortDir === 'asc' ? 1 : -1; + return [...events].sort((a, b) => { + let av, bv; + if (key === 'attribution') { + // Sort by location name so attributed rows group together. + av = a.attribution ? (a.attribution.location_name || '') : ''; + bv = b.attribution ? (b.attribution.location_name || '') : ''; + } else { + av = a[key]; bv = b[key]; + } + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir; + return String(av).localeCompare(String(bv)) * dir; + }); +} + +function _ueSortIndicator(key) { + if (_ueSortKey !== key) return ''; + return _ueSortDir === 'desc' + ? '' + : ''; +} + +function _ueSortableTh(label, key) { + return ``; +} + function renderUnitEventStats(stats) { const s = stats || {}; document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString(); @@ -2269,7 +2327,8 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal return; } - const rows = events.map(ev => { + const sorted = _ueApplySort(events); + const rows = sorted.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); @@ -2295,13 +2354,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
TimestampSerialProjectTranVertLongPVSMicFlags
+ ${label}${_ueSortIndicator(key)} +
- - - - - - - + ${_ueSortableTh('Timestamp', 'timestamp')} + ${_ueSortableTh('Tran', 'tran_ppv')} + ${_ueSortableTh('Vert', 'vert_ppv')} + ${_ueSortableTh('Long', 'long_ppv')} + ${_ueSortableTh('PVS', 'peak_vector_sum')} + ${_ueSortableTh('Flags', 'false_trigger')} + ${_ueSortableTh('Attribution', 'attribution')} ${rows}
TimestampTranVertLongPVSFlagsAttribution