Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2905a327be | |||
| 4b2bb9a9c9 | |||
| 1d9fd00cc2 | |||
| db8d666aa1 | |||
| b2bfa6d268 |
@@ -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()
|
||||||
@@ -135,6 +135,9 @@ class UserPreferences(Base):
|
|||||||
calibration_warning_days = Column(Integer, default=30)
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
status_ok_threshold_hours = Column(Integer, default=12)
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
status_pending_threshold_hours = Column(Integer, default=24)
|
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)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ class PreferencesUpdate(BaseModel):
|
|||||||
calibration_warning_days: Optional[int] = None
|
calibration_warning_days: Optional[int] = None
|
||||||
status_ok_threshold_hours: Optional[int] = None
|
status_ok_threshold_hours: Optional[int] = None
|
||||||
status_pending_threshold_hours: Optional[int] = None
|
status_pending_threshold_hours: Optional[int] = None
|
||||||
|
mic_unit_pref: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences")
|
@router.get("/preferences")
|
||||||
@@ -293,6 +294,7 @@ def get_preferences(db: Session = Depends(get_db)):
|
|||||||
"calibration_warning_days": prefs.calibration_warning_days,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_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
|
"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,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_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
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,27 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const MODAL_ID = 'event-detail-modal';
|
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) {
|
function _esc(s) {
|
||||||
if (s == null) return '';
|
if (s == null) return '';
|
||||||
return String(s).replace(/&/g, '&')
|
return String(s).replace(/&/g, '&')
|
||||||
@@ -224,32 +245,370 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 `<div class="bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" id="event-review-ft" ${ft ? 'checked' : ''}
|
||||||
|
class="w-4 h-4 rounded text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="font-medium">Flag as false trigger</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2 text-sm flex-1 min-w-[180px]">
|
||||||
|
<label for="event-review-reviewer" class="text-gray-500">Reviewer</label>
|
||||||
|
<input type="text" id="event-review-reviewer" value="${_esc(reviewer)}"
|
||||||
|
placeholder="Initials or name"
|
||||||
|
class="flex-1 px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="event-review-notes" class="block text-xs text-gray-500 mb-1">Notes</label>
|
||||||
|
<textarea id="event-review-notes" rows="2"
|
||||||
|
placeholder="Optional context — what caused the FT, follow-up actions, etc."
|
||||||
|
class="w-full px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">${_esc(notes)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 mt-3">
|
||||||
|
<span id="event-review-status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
|
||||||
|
</span>
|
||||||
|
<button type="button"
|
||||||
|
onclick="window.saveEventReview('${_esc(eventId)}')"
|
||||||
|
class="px-4 py-1.5 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <html> 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 = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
|
||||||
|
No waveform samples decoded — codec walker returned 0 valid blocks for this event.
|
||||||
|
</div>`;
|
||||||
|
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 = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
|
||||||
|
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) {
|
function _renderFileInfo(s, eventId) {
|
||||||
const bw = s.blastware || {};
|
const bw = s.blastware || {};
|
||||||
const src = s.source || {};
|
const src = s.source || {};
|
||||||
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
||||||
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
|
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 = `
|
const downloadButtons = `
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<button type="button"
|
||||||
|
onclick="window.toggleEventPdfPreview()"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="event-pdf-toggle-label">Show Event Report PDF</span>
|
||||||
|
</button>
|
||||||
|
<a href="${reportPdfUrl}" download
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download PDF
|
||||||
|
</a>
|
||||||
${canDownloadBinary ? `
|
${canDownloadBinary ? `
|
||||||
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
||||||
download="${_esc(bw.filename)}"
|
download="${_esc(bw.filename)}"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Download Blastware file
|
Blastware binary
|
||||||
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
|
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span>
|
||||||
</a>
|
</a>
|
||||||
` : `
|
` : ''}
|
||||||
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
|
${txtFilename ? `
|
||||||
|
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Blastware file unavailable
|
Original .TXT report
|
||||||
</span>
|
</a>
|
||||||
`}
|
` : ''}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="window.toggleEventJsonViewer()"
|
onclick="window.toggleEventJsonViewer()"
|
||||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
@@ -267,6 +626,11 @@
|
|||||||
Download sidecar JSON
|
Download sidecar JSON
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="event-pdf-preview" class="hidden mb-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-slate-900">
|
||||||
|
<iframe id="event-pdf-iframe" title="Event Report PDF preview"
|
||||||
|
class="w-full" style="height:80vh; min-height:600px; border:0;"
|
||||||
|
data-pdf-url="${reportPdfUrl}"></iframe>
|
||||||
|
</div>
|
||||||
<div id="event-json-viewer" class="hidden mb-4">
|
<div id="event-json-viewer" class="hidden mb-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
||||||
@@ -345,6 +709,10 @@
|
|||||||
${_sectionHeader('Peak Particle Velocity')}
|
${_sectionHeader('Peak Particle Velocity')}
|
||||||
${_renderPeakValues(s)}
|
${_renderPeakValues(s)}
|
||||||
|
|
||||||
|
${_sectionHeader('Waveform')}
|
||||||
|
<div id="event-waveform-status" class="text-xs text-gray-500 dark:text-gray-400 italic mb-2">Loading waveform…</div>
|
||||||
|
<div id="event-waveform-charts" class="space-y-0.5"></div>
|
||||||
|
|
||||||
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
||||||
${_sectionHeader('Microphone')}
|
${_sectionHeader('Microphone')}
|
||||||
${_renderMic(s)}
|
${_renderMic(s)}
|
||||||
@@ -358,14 +726,43 @@
|
|||||||
${_renderDeviceMetadata(s)}
|
${_renderDeviceMetadata(s)}
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
${_sectionHeader('Review')}
|
||||||
|
${_renderReview(s, eventId)}
|
||||||
|
|
||||||
${_sectionHeader('Source File')}
|
${_sectionHeader('Source File')}
|
||||||
${_renderFileInfo(s, eventId)}
|
${_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 () {
|
window.closeEventDetailModal = function () {
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
if (modal) modal.classList.add('hidden');
|
if (modal) modal.classList.add('hidden');
|
||||||
|
_destroyCharts();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.toggleEventJsonViewer = function () {
|
window.toggleEventJsonViewer = function () {
|
||||||
@@ -376,6 +773,71 @@
|
|||||||
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
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 () {
|
window.copyEventJson = function () {
|
||||||
const pre = document.getElementById('event-json-pre');
|
const pre = document.getElementById('event-json-pre');
|
||||||
const label = document.getElementById('event-json-copy-label');
|
const label = document.getElementById('event-json-copy-label');
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ services:
|
|||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||||
- SFM_BASE_URL=http://sfm:8200
|
- 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
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
@@ -56,9 +60,18 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../seismo-relay/sfm/data:/app/sfm/data
|
- ../seismo-relay/sfm/data:/app/sfm/data
|
||||||
- ../seismo-relay/bridges/captures:/app/bridges/captures
|
- ../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:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8200
|
- 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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||||
|
|||||||
@@ -192,8 +192,9 @@ function renderTable() {
|
|||||||
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
||||||
: '';
|
: '';
|
||||||
const checked = _selected.has(ev.id) ? 'checked' : '';
|
const checked = _selected.has(ev.id) ? 'checked' : '';
|
||||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
|
||||||
<td class="px-3 py-2"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
|
onclick="showEventDetail('${_esc(ev.id)}')">
|
||||||
|
<td class="px-3 py-2" onclick="event.stopPropagation()"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
|
||||||
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
|
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
|
||||||
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
|
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
|
||||||
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
|
||||||
@@ -355,5 +356,15 @@ async function flagSelected(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initial empty state — let the user choose to load.
|
// 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();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||||
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
|
<script src="/static/event-modal.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ Usage:
|
|||||||
#}
|
#}
|
||||||
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
|
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
|
||||||
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
|
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
|
||||||
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
|
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-5xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[92vh] overflow-y-auto">
|
||||||
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
|
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 z-10">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
||||||
<button onclick="closeEventDetailModal()"
|
<button onclick="closeEventDetailModal()"
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||||
@@ -23,3 +23,7 @@ Usage:
|
|||||||
<div id="event-detail-modal-content"></div>
|
<div id="event-detail-modal-content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{# 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. #}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
|||||||
+21
-1
@@ -122,6 +122,21 @@
|
|||||||
How often the dashboard should refresh automatically
|
How often the dashboard should refresh automatically
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Event-Report Mic Units -->
|
||||||
|
<div>
|
||||||
|
<label for="mic-unit-pref" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Event Report — Mic Channel Units
|
||||||
|
</label>
|
||||||
|
<select id="mic-unit-pref"
|
||||||
|
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="dBL" selected>dB(L) — sound pressure level</option>
|
||||||
|
<option value="psi">psi — raw pressure</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
@@ -771,6 +786,9 @@ async function loadPreferences() {
|
|||||||
// Load auto-refresh interval
|
// Load auto-refresh interval
|
||||||
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
|
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
|
// Load status thresholds
|
||||||
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
||||||
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
|
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 timezone = document.getElementById('timezone-select').value;
|
||||||
const theme = document.querySelector('input[name="theme"]:checked').value;
|
const theme = document.querySelector('input[name="theme"]:checked').value;
|
||||||
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
|
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
|
||||||
|
const micUnitPref = document.getElementById('mic-unit-pref').value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/settings/preferences', {
|
const response = await fetch('/api/settings/preferences', {
|
||||||
@@ -796,7 +815,8 @@ async function saveGeneralSettings() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
timezone,
|
timezone,
|
||||||
theme,
|
theme,
|
||||||
auto_refresh_interval: autoRefreshInterval
|
auto_refresh_interval: autoRefreshInterval,
|
||||||
|
mic_unit_pref: micUnitPref
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,13 @@
|
|||||||
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<script src="/static/event-modal.js"></script>
|
||||||
|
<script>
|
||||||
|
// 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 (typeof loadEvents === 'function') loadEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sfm-tab {
|
.sfm-tab {
|
||||||
|
|||||||
@@ -3720,5 +3720,11 @@ function showToast(message, type = 'info') {
|
|||||||
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<script src="/static/event-modal.js"></script>
|
||||||
|
<script>
|
||||||
|
// Refresh the unit's events table when the modal's review form saves.
|
||||||
|
window.addEventListener('sfm-event-review-saved', () => {
|
||||||
|
if (typeof loadUnitEvents === 'function') loadUnitEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -992,4 +992,10 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
|||||||
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<script src="/static/event-modal.js"></script>
|
||||||
|
<script>
|
||||||
|
// Refresh the location's events table when the modal's review form saves.
|
||||||
|
window.addEventListener('sfm-event-review-saved', () => {
|
||||||
|
if (typeof loadLocationEvents === 'function') loadLocationEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user