From b2bfa6d268649c47616c6baf4350c2711222929d Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 28 May 2026 05:41:26 +0000
Subject: [PATCH 1/9] compose: set TZ=America/New_York on terra-view + sfm
services
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Default display timezone for server logs + PDF report rendering on
both terra-view and sfm services. Override per-deployment in this
file for non-US-East installations.
DB columns are always UTC regardless — only affects what operators
see in logs / PDFs / any text-rendered timestamp. Modal display
uses browser TZ via toLocaleString (no server config needed).
Pairs with seismo-relay commit 6381dcb (tz env var support in the
Dockerfile + report_pdf UTC→local conversion).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
docker-compose.yml | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/docker-compose.yml b/docker-compose.yml
index a9a1ca2..77f682e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,6 +11,10 @@ services:
- ENVIRONMENT=production
- SLMM_BASE_URL=http://host.docker.internal:8100
- SFM_BASE_URL=http://sfm:8200
+ # Display timezone for server logs + any text-rendered timestamps.
+ # DB columns are stored UTC regardless; this only affects what
+ # operators see. Override here for non-US-East deployments.
+ - TZ=America/New_York
restart: unless-stopped
depends_on:
- slmm
@@ -59,6 +63,10 @@ services:
environment:
- PYTHONUNBUFFERED=1
- PORT=8200
+ # Display timezone — affects server log timestamps, the PDF
+ # report renderer's UTC→local conversions, and matplotlib's
+ # datetime axes. DB columns (created_at etc.) are always UTC.
+ - TZ=America/New_York
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
--
2.52.0
From db8d666aa1d3150943f30f4158af16f711e10f54 Mon Sep 17 00:00:00 2001
From: serversdown
Date: Fri, 29 May 2026 00:56:41 +0000
Subject: [PATCH 2/9] settings: add mic_unit_pref for event-report chart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New UserPreferences field controls the mic channel's unit on the
SFM event-detail modal's waveform chart only. "dBL" default,
"psi" alternate. Peaks everywhere else (tables, KPI tiles, modal
summary) stay in dBL regardless — this is strictly a chart-axis
preference.
Surfaced as a single dropdown on Settings → General, below the
auto-refresh interval.
Setting up the storage half ahead of the chart port in the next
commit, so the chart can read the value from /api/settings/preferences
on first render instead of needing a follow-up wiring pass.
Includes idempotent backend/migrate_add_mic_unit_pref.py for fleets
already on an older schema.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
backend/migrate_add_mic_unit_pref.py | 56 ++++++++++++++++++++++++++++
backend/models.py | 3 ++
backend/routers/settings.py | 3 ++
templates/settings.html | 22 ++++++++++-
4 files changed, 83 insertions(+), 1 deletion(-)
create mode 100644 backend/migrate_add_mic_unit_pref.py
diff --git a/backend/migrate_add_mic_unit_pref.py b/backend/migrate_add_mic_unit_pref.py
new file mode 100644
index 0000000..b471949
--- /dev/null
+++ b/backend/migrate_add_mic_unit_pref.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""
+Database migration: Add mic_unit_pref column to user_preferences.
+
+Adds a single field controlling the mic channel's unit on the event-
+report waveform chart in the SFM event detail modal. "dBL" (default)
+or "psi". Peaks and KPI tiles elsewhere are always dBL regardless.
+
+Idempotent — safe to re-run.
+"""
+
+import sqlite3
+from pathlib import Path
+
+
+def migrate():
+ possible_paths = [
+ Path("data/seismo_fleet.db"),
+ Path("data/sfm.db"),
+ Path("data/seismo.db"),
+ ]
+ db_path = next((p for p in possible_paths if p.exists()), None)
+ if db_path is None:
+ print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
+ print("Will be created with the new column when models.py initialises.")
+ return
+
+ print(f"Using database: {db_path}")
+ conn = sqlite3.connect(db_path)
+ cur = conn.cursor()
+
+ cur.execute("PRAGMA table_info(user_preferences)")
+ existing = {row[1] for row in cur.fetchall()}
+
+ if "mic_unit_pref" in existing:
+ print("mic_unit_pref already exists — nothing to do.")
+ conn.close()
+ return
+
+ cur.execute(
+ "ALTER TABLE user_preferences "
+ "ADD COLUMN mic_unit_pref TEXT DEFAULT 'dBL'"
+ )
+ # Backfill the single row that should exist (id=1) to the default,
+ # in case the column ends up NULL on existing rows.
+ cur.execute(
+ "UPDATE user_preferences SET mic_unit_pref = 'dBL' "
+ "WHERE mic_unit_pref IS NULL"
+ )
+ conn.commit()
+ conn.close()
+ print("Added mic_unit_pref to user_preferences (default 'dBL').")
+
+
+if __name__ == "__main__":
+ migrate()
diff --git a/backend/models.py b/backend/models.py
index 3ba1e11..5be50ea 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -135,6 +135,9 @@ class UserPreferences(Base):
calibration_warning_days = Column(Integer, default=30)
status_ok_threshold_hours = Column(Integer, default=12)
status_pending_threshold_hours = Column(Integer, default=24)
+ # Mic display units on the event-report waveform chart only — peaks
+ # and KPI tiles elsewhere are always dBL. "dBL" (default) or "psi".
+ mic_unit_pref = Column(String, default="dBL")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
diff --git a/backend/routers/settings.py b/backend/routers/settings.py
index e32f4d6..3b01e70 100644
--- a/backend/routers/settings.py
+++ b/backend/routers/settings.py
@@ -267,6 +267,7 @@ class PreferencesUpdate(BaseModel):
calibration_warning_days: Optional[int] = None
status_ok_threshold_hours: Optional[int] = None
status_pending_threshold_hours: Optional[int] = None
+ mic_unit_pref: Optional[str] = None
@router.get("/preferences")
@@ -293,6 +294,7 @@ def get_preferences(db: Session = Depends(get_db)):
"calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
+ "mic_unit_pref": prefs.mic_unit_pref or "dBL",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
}
@@ -334,6 +336,7 @@ def update_preferences(
"calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
+ "mic_unit_pref": prefs.mic_unit_pref or "dBL",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
}
diff --git a/templates/settings.html b/templates/settings.html
index ba5532e..662a70e 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -122,6 +122,21 @@
How often the dashboard should refresh automatically
+
+
+
+
+ Event Report — Mic Channel Units
+
+
+ dB(L) — sound pressure level
+ psi — raw pressure
+
+
+ Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
+
+
@@ -771,6 +786,9 @@ async function loadPreferences() {
// Load auto-refresh interval
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
+ // Load event-report mic units
+ document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'dBL';
+
// Load status thresholds
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
@@ -788,6 +806,7 @@ async function saveGeneralSettings() {
const timezone = document.getElementById('timezone-select').value;
const theme = document.querySelector('input[name="theme"]:checked').value;
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
+ const micUnitPref = document.getElementById('mic-unit-pref').value;
try {
const response = await fetch('/api/settings/preferences', {
@@ -796,7 +815,8 @@ async function saveGeneralSettings() {
body: JSON.stringify({
timezone,
theme,
- auto_refresh_interval: autoRefreshInterval
+ auto_refresh_interval: autoRefreshInterval,
+ mic_unit_pref: micUnitPref
})
});
--
2.52.0
From 1d9fd00cc298de23d592988c4069dec5c0e2ba68 Mon Sep 17 00:00:00 2001
From: serversdown
Date: Fri, 29 May 2026 01:01:51 +0000
Subject: [PATCH 3/9] event-modal: port 4-channel Chart.js waveform/histogram
panels
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds inline waveform plots to the shared event-detail modal, ported
from sfm/sfm_webapp.html:2555-2880. The standalone SFM webapp's
plot logic moves into event-modal.js with Tailwind-friendly grid +
tick colors (theme-aware via the `dark` class on ).
Channels render in BW Event Report order — MicL on top, Tran on
bottom. Mic channel auto-converts psi → dB(L) when the operator's
mic_unit_pref is "dBL" (the default), using _psiToDblForChart with
a MIC_DBL_FLOOR=60 floor so the chart shows an SPL-vs-time curve
instead of a sparse pattern of "moments above floor".
Histograms render as bars with HH:MM:SS x-axis labels when the
sidecar carries time_axis.interval_times (events ingested with the
v0.20 parser); falls back to interval index for older events.
Geo + mic histogram channels enforce minimum Y ranges (0.05 in/s
and 0.001 psi respectively) so quiet events don't fill the panel.
Waveform events get the trigger-line + zero-baseline overlay; the
histogram branch suppresses it (no trigger concept). Downsampling
kicks in at >3000 samples to keep render time bounded.
Modal partial widened max-w-3xl → max-w-5xl to fit the chart panels
without horizontal clipping. Chart.js 4.4.1 loaded from cdn.jsdelivr
at the bottom of the partial, matching the standalone webapp's
reference version pin.
Side-yard: docker-compose bind-mounts ../seismo-relay-prod-snap into
the SFM container so the symlinked DB + waveform store inside
bridges/captures resolve. Without it SFM 500s on every /db/* call
because the symlink target was outside the container's filesystem
view. Read-write (not :ro) because SFM opens the DB in WAL mode
which requires creating -wal and -shm sidecar files even for reads.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
backend/static/event-modal.js | 328 +++++++++++++++++++++
docker-compose.yml | 5 +
templates/partials/event_detail_modal.html | 8 +-
3 files changed, 339 insertions(+), 2 deletions(-)
diff --git a/backend/static/event-modal.js b/backend/static/event-modal.js
index b41d961..ec210cf 100644
--- a/backend/static/event-modal.js
+++ b/backend/static/event-modal.js
@@ -28,6 +28,27 @@
(function () {
const MODAL_ID = 'event-detail-modal';
+ // ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ──
+ const _CHANNEL_COLORS = {
+ MicL: '#e066ff', // purple — distinct from the geo channels
+ Long: '#3b82f6', // blue
+ Vert: '#22c55e', // green
+ Tran: '#ef4444', // red
+ };
+ const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
+
+ // dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit).
+ const DBL_REF = 2.9e-9;
+ // Mic display floor — sound-pressure AC samples sit at the digitisation
+ // noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without
+ // a floor, the chart looks like a sparse pattern of "moments when sound
+ // briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve.
+ const MIC_DBL_FLOOR = 60;
+
+ let _charts = {}; // ch → Chart instance
+ let _micUnitPref = 'dBL'; // refreshed via fetch on first chart render
+ let _micUnitPrefLoaded = false; // one-shot fetch guard
+
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&')
@@ -224,6 +245,283 @@
`;
}
+ // ── Waveform / histogram chart helpers ──────────────────────────
+
+ async function _loadMicUnitPref() {
+ if (_micUnitPrefLoaded) return _micUnitPref;
+ try {
+ const r = await fetch('/api/settings/preferences');
+ if (r.ok) {
+ const prefs = await r.json();
+ _micUnitPref = prefs.mic_unit_pref === 'psi' ? 'psi' : 'dBL';
+ }
+ } catch (e) {
+ // Network error → silent fall back to default 'dBL'.
+ }
+ _micUnitPrefLoaded = true;
+ return _micUnitPref;
+ }
+
+ function _psiToDbl(psi) {
+ if (psi == null || !(psi > 0)) return null;
+ return 20 * Math.log10(psi / DBL_REF);
+ }
+
+ // Rectifying psi→dBL converter for per-sample values — see comments in
+ // sfm_webapp.html:2592-2607 for the floor rationale.
+ function _psiToDblForChart(psi) {
+ if (psi == null) return MIC_DBL_FLOOR;
+ const a = Math.abs(psi);
+ if (a === 0) return MIC_DBL_FLOOR;
+ const dbl = 20 * Math.log10(a / DBL_REF);
+ return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
+ }
+
+ // Adaptive decimal formatter — sensible precision in the normal range,
+ // scientific notation only at the extremes.
+ function _fmtPeak(v, unit) {
+ if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
+ if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
+ if (v === 0) return '0' + (unit ? ' ' + unit : '');
+ const a = Math.abs(v);
+ const u = unit ? ' ' + unit : '';
+ if (a >= 0.0001 && a < 10000) {
+ const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
+ return v.toFixed(d) + u;
+ }
+ return v.toExponential(2) + u;
+ }
+
+ function _destroyCharts() {
+ Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
+ _charts = {};
+ }
+
+ // Returns true when Tailwind dark mode is active (the `dark` class is
+ // toggled on by Terra-View's theme handler). Drives chart grid
+ // + tick colors so they have contrast on both backgrounds.
+ function _isDark() {
+ return document.documentElement.classList.contains('dark');
+ }
+
+ function _renderWaveformInto(containerId, data, micUnit) {
+ const container = document.getElementById(containerId);
+ if (!container) return;
+ container.innerHTML = '';
+ _destroyCharts();
+
+ const channels = data.channels || {};
+ const ta = data.time_axis || {};
+ const sr = ta.sample_rate || 1024;
+ const dtMs = ta.dt_ms || (1000.0 / sr);
+ const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
+ const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
+
+ const withData = _CHANNEL_ORDER.filter(ch =>
+ channels[ch] && (channels[ch].values || []).length > 0
+ );
+ const lastCh = withData[withData.length - 1];
+
+ // Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
+ // light is white. Grids + ticks need contrast on both.
+ const dark = _isDark();
+ const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
+ const tickColor = dark ? '#94a3b8' : '#64748b';
+
+ if (withData.length === 0) {
+ container.innerHTML = `
+ No waveform samples decoded — codec walker returned 0 valid blocks for this event.
+
`;
+ return;
+ }
+
+ for (const ch of _CHANNEL_ORDER) {
+ const chData = channels[ch];
+ if (!chData) continue;
+ let values = chData.values || [];
+ let chUnit = chData.unit || '';
+ let chPeak = chData.peak;
+
+ // Mic: convert psi → dBL when the user pref is dBL (default).
+ if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
+ values = values.map(_psiToDblForChart);
+ chPeak = _psiToDbl(chPeak);
+ chUnit = 'dB(L)';
+ }
+
+ const wrap = document.createElement('div');
+ wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
+
+ const lbl = document.createElement('div');
+ lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
+ lbl.style.color = _CHANNEL_COLORS[ch];
+ const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
+ lbl.innerHTML = `${ch} ${peakStr} `;
+ wrap.appendChild(lbl);
+
+ if (values.length === 0) {
+ const e = document.createElement('div');
+ e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
+ e.textContent = 'no samples decoded';
+ wrap.appendChild(e);
+ container.appendChild(wrap);
+ continue;
+ }
+
+ const canvasWrap = document.createElement('div');
+ canvasWrap.className = 'relative';
+ canvasWrap.style.height = '100px';
+ const canvas = document.createElement('canvas');
+ canvasWrap.appendChild(canvas);
+ wrap.appendChild(canvasWrap);
+ container.appendChild(wrap);
+
+ // X-axis: waveforms use ms-relative-to-trigger; histograms use
+ // the BW-reported interval timestamps (HH:MM:SS) when the server
+ // aggregated to BW intervals, else interval index.
+ let times;
+ if (isHistogram) {
+ const intervalTimes = ta.interval_times || [];
+ times = (intervalTimes.length === values.length)
+ ? intervalTimes
+ : values.map((_, i) => i + 1);
+ } else {
+ times = values.map((_, i) => t0Ms + i * dtMs);
+ }
+
+ // Downsample for rendering when very long.
+ const MAX = 3000;
+ let rT = times, rV = values;
+ if (values.length > MAX) {
+ const step = Math.ceil(values.length / MAX);
+ rT = times.filter((_, i) => i % step === 0);
+ rV = values.filter((_, i) => i % step === 0);
+ }
+ const showX = (ch === lastCh);
+
+ const xAxisLabel = isHistogram ? '' : ' ms';
+ const fmtTick = i => {
+ const v = rT[i];
+ if (typeof v === 'number') {
+ const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
+ return s + xAxisLabel;
+ }
+ return String(v) + xAxisLabel;
+ };
+
+ // Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
+ let yBounds = {};
+ const isGeo = ch !== 'MicL';
+ if (isGeo && !isHistogram) {
+ let absMax = 0;
+ for (const v of values) {
+ const a = Math.abs(v);
+ if (a > absMax) absMax = a;
+ }
+ const padded = (absMax || 1) * 1.10;
+ yBounds = { min: -padded, max: padded };
+ } else if (isGeo && isHistogram) {
+ const HIST_GEO_MIN_INS = 0.05;
+ let peak = 0;
+ for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
+ yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
+ } else if (ch === 'MicL' && micUnit === 'dBL') {
+ const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
+ ? chPeak + 5 : 100;
+ yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
+ } else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
+ const HIST_MIC_MIN_PSI = 0.001;
+ let peak = 0;
+ for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
+ yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
+ }
+
+ _charts[ch] = new Chart(canvas, {
+ type: isHistogram ? 'bar' : 'line',
+ data: {
+ labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
+ datasets: isHistogram ? [{
+ data: rV,
+ backgroundColor: _CHANNEL_COLORS[ch],
+ borderWidth: 0,
+ barPercentage: 1.0,
+ categoryPercentage: 1.0,
+ }] : [{
+ data: rV,
+ borderColor: _CHANNEL_COLORS[ch],
+ borderWidth: 1,
+ pointRadius: 0,
+ tension: 0,
+ }],
+ },
+ options: {
+ animation: false, responsive: true, maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ mode: 'index', intersect: false,
+ callbacks: {
+ title: items => isHistogram
+ ? `interval ${items[0].label}`
+ : `t = ${items[0].label} ms`,
+ label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
+ },
+ },
+ },
+ scales: {
+ x: {
+ type: 'category', display: showX,
+ ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
+ grid: { color: gridColor, drawTicks: showX },
+ },
+ y: {
+ ...yBounds,
+ ticks: { color: tickColor, maxTicksLimit: 4 },
+ grid: { color: gridColor },
+ title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
+ },
+ },
+ },
+ plugins: isHistogram ? [] : [{
+ id: 'overlays',
+ afterDraw(chart) {
+ const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
+ const zi = rT.findIndex(t => parseFloat(t) >= 0);
+ if (zi >= 0) {
+ const px = x.getPixelForValue(zi);
+ ctx.save();
+ ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
+ ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
+ ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
+ ctx.save();
+ ctx.fillStyle = '#ef4444';
+ ctx.beginPath();
+ ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
+ ctx.closePath(); ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
+ ctx.closePath(); ctx.fill();
+ ctx.restore();
+ }
+ const zy = y.getPixelForValue(0);
+ if (zy >= y.top && zy <= y.bottom) {
+ ctx.save();
+ ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
+ ctx.setLineDash([2, 2]);
+ ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
+ ctx.restore();
+ ctx.save();
+ ctx.fillStyle = tickColor; ctx.font = '10px monospace';
+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
+ ctx.fillText('0.0', x.right + 6, zy);
+ ctx.restore();
+ }
+ },
+ }],
+ });
+ }
+ }
+
function _renderFileInfo(s, eventId) {
const bw = s.blastware || {};
const src = s.source || {};
@@ -345,6 +643,10 @@
${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)}
+ ${_sectionHeader('Waveform')}
+ Loading waveform…
+
+
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
${_sectionHeader('Microphone')}
${_renderMic(s)}
@@ -361,11 +663,37 @@
${_sectionHeader('Source File')}
${_renderFileInfo(s, eventId)}
`;
+
+ // Waveform load runs after the sidecar content is in the DOM, in
+ // parallel with the mic-unit-pref fetch. Either may complete first.
+ try {
+ const [wfRes, micUnit] = await Promise.all([
+ fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`),
+ _loadMicUnitPref(),
+ ]);
+ if (wfRes.status === 404) {
+ document.getElementById('event-waveform-status').textContent =
+ 'No waveform data — codec returned 0 valid blocks for this event.';
+ return;
+ }
+ if (!wfRes.ok) {
+ document.getElementById('event-waveform-status').textContent =
+ 'Failed to load waveform: HTTP ' + wfRes.status;
+ return;
+ }
+ const wfData = await wfRes.json();
+ document.getElementById('event-waveform-status').textContent = '';
+ _renderWaveformInto('event-waveform-charts', wfData, micUnit);
+ } catch (e) {
+ const st = document.getElementById('event-waveform-status');
+ if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message);
+ }
};
window.closeEventDetailModal = function () {
const modal = document.getElementById(MODAL_ID);
if (modal) modal.classList.add('hidden');
+ _destroyCharts();
};
window.toggleEventJsonViewer = function () {
diff --git a/docker-compose.yml b/docker-compose.yml
index 77f682e..dddde41 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -60,6 +60,11 @@ services:
volumes:
- ../seismo-relay/sfm/data:/app/sfm/data
- ../seismo-relay/bridges/captures:/app/bridges/captures
+ # The DB + waveform store inside bridges/captures are symlinks
+ # pointing at the prod-snap directory. Mount its host path at
+ # the same absolute path inside the container so the symlinks
+ # resolve. Needed for SFM to query the events DB.
+ - ../seismo-relay-prod-snap:/home/serversdown/seismo-relay-prod-snap
environment:
- PYTHONUNBUFFERED=1
- PORT=8200
diff --git a/templates/partials/event_detail_modal.html b/templates/partials/event_detail_modal.html
index 68dc574..667a801 100644
--- a/templates/partials/event_detail_modal.html
+++ b/templates/partials/event_detail_modal.html
@@ -10,8 +10,8 @@ Usage:
#}
-
-
+
+
Event Detail
@@ -23,3 +23,7 @@ Usage:
+{# Chart.js — pinned to v4.4.1 to match the SFM webapp's reference impl
+ (v4 chart API; differs from v3). Loaded once globally; safe if other
+ pages on the same template tree also load it. #}
+
--
2.52.0
From 4b2bb9a9c99433b3e58184262186c82f64be176e Mon Sep 17 00:00:00 2001
From: serversdown
Date: Fri, 29 May 2026 01:04:15 +0000
Subject: [PATCH 4/9] event-modal: inline PDF preview + .TXT link + review form
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three additions to the shared event-detail modal, closing the gap
versus the standalone SFM webapp:
(1) "Show Event Report PDF" button toggles an inline iframe inside
the modal (no second-layer modal, no new tab). Lazy-loaded — src
isn't set until first reveal, so closing the modal without opening
the PDF never spends bandwidth. Sibling "Download PDF" link for
direct save. Iframe sized to 80vh / min 600px so the typical
letter-portrait single-page report fits with browser-native zoom
controls available.
(2) "Original .TXT report" download link, rendered only when
sidecar.source.txt_filename is present (post-2026-05-27 ingest
events). Hidden for legacy events to avoid 404 dead links.
(3) Inline Review form — false_trigger checkbox + reviewer text
input + notes textarea + Save button. PATCH /api/sfm/db/events/{id}/sidecar
with {"review": {...}}. On save, fires a CustomEvent
'sfm-event-review-saved' on window so table-owning pages
(/sfm, /unit/{id}, /admin/events, /projects/{p}/nrl/{l}) can
listen and refresh their FT badges without reload. Status line
shows the last-reviewed timestamp + Save success/failure feedback.
Smoke-tested end-to-end against a real BE12599 histogram event:
PATCH round-trip lands in the sidecar, GET reflects the change,
no 500s on /report.pdf or /sidecar paths through the proxy.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
backend/static/event-modal.js | 152 ++++++++++++++++++++++++++++++++--
1 file changed, 143 insertions(+), 9 deletions(-)
diff --git a/backend/static/event-modal.js b/backend/static/event-modal.js
index ec210cf..162bced 100644
--- a/backend/static/event-modal.js
+++ b/backend/static/event-modal.js
@@ -245,6 +245,47 @@
`;
}
+ function _renderReview(s, eventId) {
+ const rev = s.review || {};
+ const ft = !!rev.false_trigger;
+ const reviewer = rev.reviewer || '';
+ const notes = rev.notes || '';
+ const reviewedAt = rev.reviewed_at
+ ? rev.reviewed_at.replace('T', ' ').slice(0, 19)
+ : null;
+ return `
+
+
+ Notes
+
+
+
+
+ ${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
+
+
+ Save
+
+
+
`;
+ }
+
// ── Waveform / histogram chart helpers ──────────────────────────
async function _loadMicUnitPref() {
@@ -527,27 +568,47 @@
const src = s.source || {};
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
+ const txtFilename = src && src.txt_filename;
+ const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`;
+ const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`;
const downloadButtons = `
+
+
+
Sidecar JSON
@@ -660,6 +726,9 @@
${_renderDeviceMetadata(s)}
` : ''}
+ ${_sectionHeader('Review')}
+ ${_renderReview(s, eventId)}
+
${_sectionHeader('Source File')}
${_renderFileInfo(s, eventId)}
`;
@@ -704,6 +773,71 @@
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
};
+ window.toggleEventPdfPreview = function () {
+ const preview = document.getElementById('event-pdf-preview');
+ const iframe = document.getElementById('event-pdf-iframe');
+ const label = document.getElementById('event-pdf-toggle-label');
+ if (!preview || !iframe) return;
+ const isHidden = preview.classList.toggle('hidden');
+ // Lazy-load the PDF: only set the iframe src on first reveal, so
+ // closing the event modal without opening the PDF never spends
+ // bandwidth on it.
+ if (!isHidden && !iframe.src) {
+ iframe.src = iframe.dataset.pdfUrl || '';
+ }
+ if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF';
+ // Scroll the iframe into view on first reveal so the operator
+ // doesn't have to hunt for it after clicking.
+ if (!isHidden) {
+ preview.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ };
+
+ window.saveEventReview = async function (eventId) {
+ const ft = document.getElementById('event-review-ft');
+ const reviewer = document.getElementById('event-review-reviewer');
+ const notes = document.getElementById('event-review-notes');
+ const status = document.getElementById('event-review-status');
+ if (!ft || !reviewer || !notes) return;
+
+ const payload = {
+ review: {
+ false_trigger: ft.checked,
+ reviewer: reviewer.value.trim() || null,
+ notes: notes.value.trim() || null,
+ }
+ };
+ if (status) {
+ status.textContent = 'Saving…';
+ status.className = 'text-xs text-gray-500 dark:text-gray-400';
+ }
+ try {
+ const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!r.ok) {
+ const t = await r.text().catch(() => '');
+ throw new Error('HTTP ' + r.status + (t ? ` — ${t.slice(0, 120)}` : ''));
+ }
+ if (status) {
+ status.textContent = 'Saved.';
+ status.className = 'text-xs text-green-600 dark:text-green-400';
+ }
+ // Notify the host page so its event-list FT badge / row state
+ // can refresh. Pages opt in by listening for this event.
+ window.dispatchEvent(new CustomEvent('sfm-event-review-saved', {
+ detail: { eventId, review: payload.review },
+ }));
+ } catch (e) {
+ if (status) {
+ status.textContent = 'Save failed: ' + e.message;
+ status.className = 'text-xs text-red-600 dark:text-red-400';
+ }
+ }
+ };
+
window.copyEventJson = function () {
const pre = document.getElementById('event-json-pre');
const label = document.getElementById('event-json-copy-label');
--
2.52.0
From 2905a327bee93fca17ba8a055fbbede8247e85bf Mon Sep 17 00:00:00 2001
From: serversdown
Date: Fri, 29 May 2026 01:06:44 +0000
Subject: [PATCH 5/9] admin_events: wire shared event-detail modal into the
page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/admin/events previously rendered events as a flat table with no
detail view — admins had to copy an event ID and open the standalone
SFM webapp on port 8200 to see the chart, PDF, or sidecar metadata.
Adds:
- {% include 'partials/event_detail_modal.html' %} + script tag at
the bottom of the page (mirrors the pattern in /sfm, /unit/{id},
/projects/.../nrl/...).
- onclick on the table opens the modal via showEventDetail(id).
- event.stopPropagation() on the checkbox so selection clicks
don't also open the modal.
- Listener for the 'sfm-event-review-saved' CustomEvent fired by
event-modal.js — reloads the table so any FT-flag changes made in
the modal's review form land on the row without a full reload.
Also propagates the same listener pattern to the three other pages
that already include the modal (sfm.html, unit_detail.html,
vibration_location_detail.html) — they call their respective
loadEvents / loadUnitEvents / loadLocationEvents on the fire. Keeps
the refresh-on-save UX consistent across every page that hosts the
modal.
Phase 1 of the SFM-into-Terra-View integration is now complete:
chart, PDF preview, .TXT download, review form, and per-unit + admin
event browsing are all native in Terra-View. The standalone SFM
webapp on port 8200 remains as a diagnostic fallback but operators
no longer need to bounce to it for routine workflows.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
templates/admin_events.html | 15 +++++++++++++--
templates/sfm.html | 7 +++++++
templates/unit_detail.html | 6 ++++++
templates/vibration_location_detail.html | 6 ++++++
4 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/templates/admin_events.html b/templates/admin_events.html
index 9f45799..729330d 100644
--- a/templates/admin_events.html
+++ b/templates/admin_events.html
@@ -192,8 +192,9 @@ function renderTable() {
? 'FT '
: '';
const checked = _selected.has(ev.id) ? 'checked' : '';
- return `
-
+ return `
+
${_esc(ev.serial)}
${_esc(ts)}
${_fmtPpv(ev.tran_ppv)}
@@ -355,5 +356,15 @@ async function flagSelected(value) {
}
// Initial empty state — let the user choose to load.
+
+// Refresh the events table when the modal's review form saves — keeps
+// the FT badge in sync without a full page reload.
+window.addEventListener('sfm-event-review-saved', () => {
+ if (_events.length) loadEvents();
+});
+
+{# Shared event-detail modal — rendered by /static/event-modal.js #}
+{% include 'partials/event_detail_modal.html' %}
+
{% endblock %}
diff --git a/templates/sfm.html b/templates/sfm.html
index bbb94e2..8f6283f 100644
--- a/templates/sfm.html
+++ b/templates/sfm.html
@@ -118,6 +118,13 @@
{# Shared event-detail modal — rendered by /static/event-modal.js #}
{% include 'partials/event_detail_modal.html' %}
+