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
`}
+
+
+
+
+ View JSON
+
@@ -232,6 +264,16 @@
Download sidecar JSON
+
+
+ Sidecar JSON
+
+ Copy
+
+
+
${_esc(JSON.stringify(s, null, 2))}
+
`;
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 = '
';
@@ -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) {
- Timestamp
- Serial
- Project
- Tran
- Vert
- Long
- PVS
- Mic
- Flags
+ ${_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 `
+ ${label}${_ueSortIndicator(key)}
+ `;
+}
+
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
- Timestamp
- Tran
- Vert
- Long
- PVS
- Flags
- Attribution
+ ${_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}