feat: unify DB and live waveform views with inline modal overlay

- Extract _buildWaveformCharts() shared renderer used by both live Events
  tab and new DB history modal (no duplicate chart-building code)
- Replace window.open(waveform_viewer.html) with openDbWaveformModal()
  that renders an inline overlay with full peaks bar, debug panel, and
  4-channel charts — same rendering path as the live device view
- Fix timestamp display for DB blobs (ISO string vs {display:...} object)
- Normalize old blob peak_values keys (tran/vert/long → tran_in_s etc.)
  for backward compat with pre-fix ACH blobs
- Close modal via × button, Esc key, or backdrop click; destroy Chart.js
  instances on close to free canvas memory
- Fix onclick UUID quoting in History table (UUIDs need quoted string arg)
- Fix ach_server.py peak_values key names to match viewer expectations
- Extract _fillDebugPanel() so same debug content works in both contexts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 23:36:32 -04:00
parent 727bfed5c4
commit bbd574e7d5
5 changed files with 1858 additions and 1665 deletions
+1 -6
View File
@@ -25,9 +25,4 @@ Thumbs.db
# Analyzer outputs # Analyzer outputs
*.report *.report
claude_export_*.md claude_exp
# Frame database
*.db
*.db-wal
*.db-shm
+1 -3
View File
@@ -263,6 +263,4 @@ Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
- [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db` - [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
- [x] SFM REST API — device control + DB query endpoints, live device cache - [x] SFM REST API — device control + DB query endpoints, live device cache
- [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing - [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing
- [ ] Vibration summary reports — highest legit PPV per project → Word doc (false trigger filtering first) - [ ] Vibra
- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
+229 -29
View File
@@ -782,6 +782,12 @@
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button> <button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled></button> <button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled></button>
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button> <button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--fg-muted);cursor:pointer;margin-left:4px"
title="Bypass server cache and re-download from device. Checking this auto-reloads if a waveform is already displayed.">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastWaveformData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<div class="event-chips" id="event-chips"></div> <div class="event-chips" id="event-chips"></div>
</div> </div>
@@ -793,6 +799,14 @@
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs"></div></div> <div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs"></div></div>
</div> </div>
<!-- Debug panel: raw ADC sample readout for diagnosing decode issues -->
<div id="debug-panel" style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('debug-panel').style.display='none'">hide</span>
<div id="debug-content"></div>
</div>
<div id="waveform-area" style="flex:1; overflow-y:auto;"> <div id="waveform-area" style="flex:1; overflow-y:auto;">
<div id="empty-state"> <div id="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -1051,6 +1065,7 @@ let eventList = [];
let currentEvent = 0; let currentEvent = 0;
let charts = {}; let charts = {};
let geoRange = 6.206; let geoRange = 6.206;
let lastWaveformData = null; // last successfully rendered waveform payload
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' }; const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
@@ -1512,13 +1527,14 @@ function updatePeaksBar(ev) {
async function loadWaveform() { async function loadWaveform() {
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; } if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
const idx = currentEvent; const idx = currentEvent;
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
document.getElementById('load-btn').disabled = true; document.getElementById('load-btn').disabled = true;
setStatus('Fetching waveform…', 'loading'); setStatus('Fetching waveform…', 'loading');
let data; let data;
try { try {
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}`); const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}${force}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json(); data = await r.json();
} catch(e) { } catch(e) {
@@ -1527,46 +1543,41 @@ async function loadWaveform() {
return; return;
} }
lastWaveformData = data;
renderWaveform(data); renderWaveform(data);
document.getElementById('load-btn').disabled = false; document.getElementById('load-btn').disabled = false;
} }
function renderWaveform(data) { // ── Shared waveform chart builder ──────────────────────────────────────────────
// Renders waveform channel charts into chartsEl, destroys+replaces instances in
// chartsStore. emptyEl (optional) is shown/hidden based on decoded sample count.
function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
const sr = data.sample_rate || 1024; const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0; const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0; const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const channels = data.channels || {}; const channels = data.channels || {};
// Status bar // Destroy old chart instances
const bar = document.getElementById('status-bar'); Object.values(chartsStore).forEach(c => c.destroy());
bar.innerHTML = ''; for (const k in chartsStore) delete chartsStore[k];
bar.className = 'ok';
const ts = data.timestamp;
bar.textContent = ts ? `Event #${data.index}${ts.display} ` : `Event #${data.index} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${data.rectime_seconds ?? '?'} s`);
if (decoded === 0) { if (decoded === 0) {
document.getElementById('empty-state').style.display = 'flex'; if (emptyEl) {
document.getElementById('empty-state').querySelector('p').textContent = emptyEl.style.display = 'flex';
data.record_type === 'Waveform' const p = emptyEl.querySelector('p');
if (p) p.textContent = data.record_type === 'Waveform'
? 'No samples decoded — check server logs' ? 'No samples decoded — check server logs'
: `Record type "${data.record_type}" — waveform not supported yet`; : `Record type "${data.record_type}" — waveform not supported yet`;
document.getElementById('charts').style.display = 'none'; }
Object.values(charts).forEach(c => c.destroy()); charts = {}; chartsEl.style.display = 'none';
chartsEl.innerHTML = '';
return; return;
} }
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2)); const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
document.getElementById('empty-state').style.display = 'none'; if (emptyEl) emptyEl.style.display = 'none';
const chartsDiv = document.getElementById('charts'); chartsEl.style.display = 'flex';
chartsDiv.style.display = 'flex'; chartsEl.innerHTML = '';
chartsDiv.innerHTML = '';
Object.values(charts).forEach(c => c.destroy()); charts = {};
const micPeakPsi = data.peak_values?.micl_psi ?? null; const micPeakPsi = data.peak_values?.micl_psi ?? null;
@@ -1618,9 +1629,9 @@ function renderWaveform(data) {
const cw = document.createElement('div'); const cw = document.createElement('div');
cw.className = 'chart-canvas-wrap'; cw.className = 'chart-canvas-wrap';
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
cw.appendChild(canvas); wrap.appendChild(cw); chartsDiv.appendChild(wrap); cw.appendChild(canvas); wrap.appendChild(cw); chartsEl.appendChild(wrap);
charts[ch] = new Chart(canvas, { chartsStore[ch] = new Chart(canvas, {
type: 'line', type: 'line',
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] }, data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] },
options: { options: {
@@ -1655,6 +1666,64 @@ function renderWaveform(data) {
} }
} }
function renderWaveform(data) {
const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
lastWaveformData = data;
// Status bar
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
bar.textContent = ts ? `Event #${data.index}${ts.display} ` : `Event #${data.index} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${data.rectime_seconds ?? '?'} s`);
_buildWaveformCharts(data, document.getElementById('charts'), document.getElementById('empty-state'), charts);
updateDebugPanel(data);
}
// ── Debug panel population ─────────────────────────────────────────────────────
function _fillDebugPanel(data, dbg, cont) {
if (!dbg || !cont) return;
const channels = data.channels || {};
const pv = data.peak_values || {};
const scale = geoRange / 32767;
const geoChans = ['Tran', 'Vert', 'Long'];
let html = '<div style="display:flex;gap:24px;flex-wrap:wrap;">';
for (const ch of [...geoChans, 'Mic']) {
const raw = (channels[ch] || []).slice(0, 8);
if (raw.length === 0) continue;
const maxAbs = Math.max(...raw.map(Math.abs));
const keyMap = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' };
const p0c = ch !== 'Mic' ? (pv[keyMap[ch]] ?? null) : null;
const src = p0c !== null ? `<span style="color:#3fb950">0C=${p0c.toFixed(4)}</span>`
: `<span style="color:#e3b341">Math.max≈${(maxAbs*scale).toFixed(4)}</span>`;
html += `<div><span style="color:#8b949e">${ch} raw[0:8]:</span> <span style="color:#c9d1d9">${raw.join(', ')}</span> peak: ${src}</div>`;
}
html += '</div>';
const nullPeaks = geoChans.filter(ch => (pv[{ Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch]] ?? null) === null);
if (nullPeaks.length > 0) {
html += `<div style="color:#e3b341;margin-top:2px">⚠ peak0C null for: ${nullPeaks.join(', ')} — peaks shown are Math.max of waveform samples, not 0C record</div>`;
}
html += `<div style="color:#484f58;margin-top:2px">decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange.toFixed(3)}</div>`;
cont.innerHTML = html;
dbg.style.display = 'block';
}
function updateDebugPanel(data) {
_fillDebugPanel(data, document.getElementById('debug-panel'), document.getElementById('debug-content'));
}
// ── DB tabs ──────────────────────────────────────────────────────────────────── // ── DB tabs ────────────────────────────────────────────────────────────────────
let histLoaded = false; let histLoaded = false;
let unitsLoaded = false; let unitsLoaded = false;
@@ -1757,9 +1826,8 @@ async function loadHistory() {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const pvs = ev.peak_vector_sum; const pvs = ev.peak_vector_sum;
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0); const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
const waveformUrl = `${api()}/waveform?db_id=${encodeURIComponent(ev.id)}&api_base=${encodeURIComponent(api())}`;
tr.innerHTML = ` tr.innerHTML = `
<td><button class="wf-btn" onclick="window.open('${waveformUrl}','_blank')" title="View waveform">〜</button></td> <td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform">〜</button></td>
<td>${_fmtTs(ev.timestamp)}</td> <td>${_fmtTs(ev.timestamp)}</td>
<td class="td-key">${ev.serial ?? '—'}</td> <td class="td-key">${ev.serial ?? '—'}</td>
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td> <td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
@@ -1934,9 +2002,86 @@ async function loadSessions() {
} }
} }
// ── DB waveform modal ─────────────────────────────────────────────────────────
let modalCharts = {};
async function openDbWaveformModal(id) {
const modal = document.getElementById('wf-modal');
const titleEl = document.getElementById('wf-modal-title');
const chartsEl = document.getElementById('wf-modal-charts');
const emptyEl = document.getElementById('wf-modal-empty');
const peaksEl = document.getElementById('wf-modal-peaks');
const debugEl = document.getElementById('wf-modal-debug');
// Show modal in loading state
titleEl.textContent = 'Loading…';
peaksEl.classList.remove('visible');
if (debugEl) debugEl.style.display = 'none';
chartsEl.style.display = 'none';
chartsEl.innerHTML = '';
emptyEl.style.display = 'flex';
emptyEl.querySelector('p').textContent = 'Loading waveform…';
modal.style.display = 'flex';
let data;
try {
const r = await fetch(`${api()}/db/events/${encodeURIComponent(id)}/waveform`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch(e) {
emptyEl.querySelector('p').textContent = `Error: ${e.message}`;
return;
}
// Normalize old blob peak_values keys (pre-fix ACH blobs used tran/vert/long without _in_s)
if (data.peak_values) {
const pv = data.peak_values;
if (pv.tran_in_s == null && pv.tran != null) pv.tran_in_s = pv.tran;
if (pv.vert_in_s == null && pv.vert != null) pv.vert_in_s = pv.vert;
if (pv.long_in_s == null && pv.long != null) pv.long_in_s = pv.long;
}
// Header — DB blobs have timestamp as ISO string; live device returns {display:...}
const sr = data.sample_rate || 1024;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const pretrig = data.pretrig_samples || 0;
let tsStr = '';
if (data.timestamp) {
const tsDisplay = typeof data.timestamp === 'object'
? (data.timestamp.display || String(data.timestamp))
: new Date(data.timestamp).toLocaleString();
tsStr = `<strong style="color:var(--text)">${tsDisplay}</strong> `;
}
titleEl.innerHTML = `${tsStr}<span style="color:var(--text-dim)">${data.record_type || '?'} · ${sr} sps · ${decoded.toLocaleString()} / ${total.toLocaleString()} samples · pretrig ${pretrig} · ${data.rectime_seconds ?? '?'} s</span>`;
// Peaks bar
const pv = data.peak_values || {};
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
document.getElementById('wf-mpk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
document.getElementById('wf-mpk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
peaksEl.classList.add('visible');
_buildWaveformCharts(data, chartsEl, emptyEl, modalCharts);
_fillDebugPanel(data, debugEl, document.getElementById('wf-modal-debug-content'));
}
function closeWfModal() {
const modal = document.getElementById('wf-modal');
if (!modal || modal.style.display === 'none') return;
modal.style.display = 'none';
// Destroy chart instances to free canvas memory
Object.values(modalCharts).forEach(c => c.destroy());
for (const k in modalCharts) delete modalCharts[k];
}
// ── Keyboard shortcuts ───────────────────────────────────────────────────────── // ── Keyboard shortcuts ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'Escape') { closeWfModal(); return; }
if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); } if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); } if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
}); });
@@ -1950,5 +2095,60 @@ document.getElementById('api-base').value = window.location.origin;
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); }); document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
}); });
</script> </script>
<!-- ── Waveform Modal (DB history view) ──────────────────────────────────────
Opened by openDbWaveformModal(id). Click outside or press Esc to close. -->
<div id="wf-modal"
style="display:none; position:fixed; inset:0; z-index:1000;
background:rgba(1,4,9,0.88); align-items:flex-start;
justify-content:center; padding:24px; overflow:auto;"
onclick="if(event.target===this)closeWfModal()">
<div style="background:var(--surface); border:1px solid var(--border);
border-radius:8px; width:100%; max-width:1100px;
display:flex; flex-direction:column; max-height:calc(100vh - 48px);">
<!-- Header row -->
<div style="display:flex; align-items:center; padding:10px 16px;
border-bottom:1px solid var(--border); flex-shrink:0; gap:10px;">
<div id="wf-modal-title"
style="flex:1; font-size:12px; color:var(--text-dim); font-family:monospace; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">
</div>
<button onclick="closeWfModal()"
style="background:none; border:none; color:var(--text-dim); cursor:pointer;
font-size:20px; line-height:1; padding:0 2px; flex-shrink:0;"
title="Close (Esc)">×</button>
</div>
<!-- Peaks bar — reuses .peaks-bar styles from live Events tab -->
<div class="peaks-bar" id="wf-modal-peaks">
<div class="pk"><div class="pk-label">Tran</div><div class="pk-value pk-tran" id="wf-mpk-tran"></div></div>
<div class="pk"><div class="pk-label">Vert</div><div class="pk-value pk-vert" id="wf-mpk-vert"></div></div>
<div class="pk"><div class="pk-label">Long</div><div class="pk-value pk-long" id="wf-mpk-long"></div></div>
<div class="pk"><div class="pk-label">MicL</div><div class="pk-value pk-mic" id="wf-mpk-mic"></div></div>
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="wf-mpk-pvs"></div></div>
</div>
<!-- Debug panel (same as live debug panel, hidden by default) -->
<div id="wf-modal-debug"
style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('wf-modal-debug').style.display='none'">hide</span>
<div id="wf-modal-debug-content"></div>
</div>
<!-- Waveform area -->
<div style="flex:1; overflow-y:auto; min-height:200px;">
<div id="wf-modal-empty"
style="display:flex; flex-direction:column; align-items:center;
justify-content:center; padding:60px 20px; color:var(--text-dim); gap:12px;">
<p>Loading…</p>
</div>
<div id="wf-modal-charts" style="display:none;"></div>
</div>
</div>
</div>
</body> </body>
</html> </html>