Files
seismo-relay/sfm/sfm_webapp.html
T
serversdown 8cbda09917 viewers: render timestamps in browser-local time
Spotted on the SFM webapp event modal — "Received by server at" was
showing the raw ISO string "2026-05-27T21:59:57.213043Z" because we
were assigning ev.timestamp / src.captured_at directly to the
textContent of the modal fields, bypassing the existing _fmtTs()
helper that wraps them in toLocaleString().

Net effect for operators: confusing "21:59 vs it's 6 PM" mismatch
when the displayed UTC timestamp didn't match wall-clock time.  The
values were always correct; the display was just ambiguous.

After this fix:
  - "Recorded at" (naive ISO from BW = unit local time) renders
    cleanly as the unit wrote it: "5/27/2026, 6:00:13 AM"
  - "Received by server at" (UTC with Z suffix) converts to browser
    local: "5/27/2026, 5:59:57 PM"
  - Timestamp column in the history table already used _fmtTs —
    unchanged
  - Same fix applied to the standalone /events page (sidebar event
    list + meta header) via a new _fmtTsLocal helper

Note: did NOT add file-mtime-on-watcher-PC tracking as a separate
"Called in at" column — discussed and decided created_at is close
enough for schedule-compliance monitoring (worst case lag = watcher
poll interval ~60s, indistinguishable from BW write time at the
operationally-relevant resolution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:30:43 +00:00

3233 lines
128 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SFM — Seismograph Field Module</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--border2: #21262d;
--text: #c9d1d9;
--text-dim: #8b949e;
--text-mute: #484f58;
--blue: #1f6feb;
--blue-lt: #388bfd;
--green: #238636;
--green-lt: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #bc8cff;
--radius: 6px;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 13px;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ── Header ── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 18px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
flex-shrink: 0;
}
.app-title {
font-size: 14px;
font-weight: 700;
color: #f0f6fc;
letter-spacing: 0.03em;
white-space: nowrap;
}
.app-title span { color: var(--text-dim); font-weight: 400; }
.hdr-group {
display: flex;
align-items: center;
gap: 6px;
}
.hdr-sep {
width: 1px;
height: 20px;
background: var(--border);
}
label.hdr { color: var(--text-dim); font-size: 11px; white-space: nowrap; }
input[type="text"], input[type="number"], select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 5px 8px;
font-size: 12px;
}
input[type="text"]:focus, input[type="number"]:focus, select:focus {
outline: none;
border-color: var(--blue);
}
input#api-base { width: 170px; }
input#dev-host { width: 120px; }
input#dev-port { width: 60px; }
.btn {
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
font-weight: 500;
padding: 5px 14px;
transition: background 0.12s;
white-space: nowrap;
}
.btn-primary { background: var(--blue); color: #fff; }
.btn-primary:hover { background: var(--blue-lt); }
.btn-success { background: var(--green); color: #fff; }
.btn-success:hover { background: var(--green-lt); }
.btn-ghost {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
}
.btn-ghost:hover { border-color: var(--blue-lt); color: var(--blue-lt); }
.btn:disabled { background: var(--surface2) !important; color: var(--text-mute) !important; cursor: not-allowed; border-color: var(--border2) !important; }
/* #connect-btn styles moved to #live-connect-bar block */
/* ── Device info bar ── */
#device-bar {
background: var(--bg);
border-bottom: 1px solid var(--border2);
padding: 7px 18px;
display: none;
align-items: center;
gap: 20px;
flex-wrap: wrap;
flex-shrink: 0;
}
.di-field { display: flex; flex-direction: column; gap: 1px; }
.di-label { color: var(--text-mute); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.di-value { color: var(--text); font-family: monospace; font-size: 13px; }
.di-value.accent { color: var(--blue-lt); font-weight: 600; }
.di-value.project-val { color: #e6edf3; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Monitor panel ── */
#monitor-panel {
background: var(--surface);
border-bottom: 1px solid var(--border2);
padding: 8px 18px;
display: none;
align-items: center;
gap: 24px;
flex-shrink: 0;
}
#monitor-panel.monitoring { border-left: 3px solid var(--green); }
#monitor-panel.idle { border-left: 3px solid var(--text-mute); }
.mon-status-badge {
font-size: 12px;
font-weight: 700;
padding: 2px 10px;
border-radius: 10px;
letter-spacing: 0.04em;
}
.mon-status-badge.monitoring { background: rgba(46,160,67,0.2); color: var(--green-lt); }
.mon-status-badge.idle { background: var(--surface2); color: var(--text-mute); }
.mon-field { display: flex; flex-direction: column; gap: 1px; }
.mon-label { color: var(--text-mute); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.mon-value { color: var(--text); font-family: monospace; font-size: 13px; }
#mon-start-btn { background: var(--green); color: #fff; }
#mon-start-btn:hover { background: var(--green-lt); }
#mon-stop-btn { background: var(--red); color: #fff; }
#mon-stop-btn:hover { filter: brightness(1.15); }
.mon-spacer { flex: 1; }
/* ── Status bar ── */
#status-bar {
background: var(--surface);
border-bottom: 1px solid var(--border2);
padding: 5px 18px;
font-size: 11px;
color: var(--text-dim);
min-height: 24px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
#status-bar.error { color: var(--red); }
#status-bar.ok { color: var(--green-lt); }
#status-bar.loading { color: var(--yellow); }
.meta-pill {
background: var(--surface2);
border-radius: 4px;
padding: 1px 7px;
color: var(--text);
font-family: monospace;
font-size: 11px;
}
/* ── Tabs ── */
.tab-bar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 18px;
display: flex;
gap: 2px;
flex-shrink: 0;
}
.tab-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
font-weight: 500;
padding: 9px 14px 7px;
transition: color 0.12s, border-color 0.12s;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: #f0f6fc; border-bottom-color: var(--blue-lt); }
/* ── Tab panes ── */
.tab-pane { display: none; flex: 1; overflow-y: auto; }
.tab-pane.active { display: block; }
/* ── Events tab ── */
#tab-events {
display: none;
flex-direction: column;
}
#tab-events.active { display: flex; }
.event-toolbar {
background: var(--surface);
border-bottom: 1px solid var(--border2);
padding: 8px 18px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
}
.event-chips {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.event-chip {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-dim);
cursor: pointer;
font-size: 11px;
padding: 3px 9px;
transition: all 0.1s;
}
.event-chip:hover { background: var(--blue); border-color: var(--blue); color: #fff; }
.event-chip.active { background: var(--blue); border-color: var(--blue-lt); color: #fff; font-weight: 600; }
.peaks-bar {
background: var(--bg);
border-bottom: 1px solid var(--border2);
padding: 6px 18px;
display: none;
gap: 20px;
flex-wrap: wrap;
align-items: center;
flex-shrink: 0;
}
.peaks-bar.visible { display: flex; }
.pk { display: flex; flex-direction: column; gap: 1px; }
.pk-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-mute); }
.pk-value { font-family: monospace; font-size: 13px; font-weight: 600; }
.pk-tran { color: #58a6ff; }
.pk-vert { color: var(--green-lt); }
.pk-long { color: var(--yellow); }
.pk-mic { color: var(--purple); }
.pk-pvs { color: #f0f6fc; }
#waveform-area { flex: 1; overflow-y: auto; }
#charts { padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; }
.chart-wrap {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 8px 12px 6px;
}
.chart-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 4px;
}
.chart-canvas-wrap { position: relative; height: 130px; }
.ch-tran { color: #58a6ff; }
.ch-vert { color: var(--green-lt); }
.ch-long { color: var(--yellow); }
.ch-mic { color: var(--purple); }
#empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--text-mute);
gap: 8px;
}
#empty-state p { font-size: 13px; }
/* ── Config tab ── */
#tab-config { padding: 20px 24px; }
.cfg-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
max-width: 860px;
}
@media (max-width: 640px) { .cfg-grid { grid-template-columns: 1fr; } }
.cfg-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.cfg-section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border2);
}
.cfg-field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.cfg-field:last-child { margin-bottom: 0; }
.cfg-field label {
font-size: 11px;
color: var(--text-dim);
font-weight: 500;
}
.cfg-field .hint {
font-size: 10px;
color: var(--text-mute);
margin-top: 2px;
}
.cfg-field input[type="text"],
.cfg-field input[type="number"],
.cfg-field select {
width: 100%;
padding: 6px 9px;
font-size: 13px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
}
.cfg-field input:focus, .cfg-field select:focus {
outline: none;
border-color: var(--blue);
}
.cfg-actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 20px;
max-width: 860px;
}
.cfg-actions .btn { padding: 7px 18px; font-size: 13px; }
#cfg-status {
font-size: 12px;
color: var(--text-dim);
}
#cfg-status.ok { color: var(--green-lt); }
#cfg-status.error { color: var(--red); }
/* ── Device tab ── */
#tab-device { padding: 20px 24px; }
.dev-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
max-width: 860px;
margin-bottom: 20px;
}
.dev-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.dev-card-label {
font-size: 10px;
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 5px;
}
.dev-card-value {
font-size: 15px;
font-weight: 600;
color: #f0f6fc;
font-family: monospace;
}
.dev-card-sub {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.dev-section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin: 20px 0 10px;
max-width: 860px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border2);
}
.dev-table {
max-width: 860px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.dev-table-row {
display: flex;
align-items: center;
padding: 9px 16px;
border-bottom: 1px solid var(--border2);
}
.dev-table-row:last-child { border-bottom: none; }
.dev-table-row:nth-child(even) { background: var(--surface); }
.dtr-label { color: var(--text-dim); font-size: 12px; width: 200px; flex-shrink: 0; }
.dtr-value { color: var(--text); font-family: monospace; font-size: 13px; }
.dtr-value.accent { color: var(--blue-lt); }
#no-device-msg {
color: var(--text-mute);
font-size: 14px;
margin-top: 40px;
}
/* ── DB tabs (History / Units / Monitor Log / Sessions) ── */
.db-tab-pane { padding: 0; flex-direction: column; overflow: hidden; }
.db-tab-pane.active { display: flex; }
.db-toolbar {
background: var(--surface);
border-bottom: 1px solid var(--border2);
padding: 8px 18px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
}
.db-toolbar label { color: var(--text-dim); font-size: 11px; white-space: nowrap; }
.db-toolbar input[type="text"],
.db-toolbar input[type="date"],
.db-toolbar select { font-size: 12px; padding: 4px 8px; }
.db-toolbar select#db-serial-filter { width: 120px; }
.db-toolbar input.date-input { width: 130px; }
.db-toolbar-spacer { flex: 1; }
.db-count-badge {
color: var(--text-mute);
font-size: 11px;
white-space: nowrap;
}
.db-scroll { flex: 1; overflow-y: auto; padding: 14px 18px; }
.db-table-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
max-width: 100%;
}
table.db-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
table.db-table thead th {
background: var(--surface2);
color: var(--text-dim);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 7px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
position: sticky;
top: 0;
z-index: 1;
}
table.db-table thead th[data-sort]:hover {
background: var(--border2);
color: var(--text);
}
table.db-table thead th .sort-arrow {
display: inline-block;
width: 10px;
color: var(--accent, #58a6ff);
font-weight: 900;
text-align: center;
}
table.db-table tbody tr { border-bottom: 1px solid var(--border2); }
table.db-table tbody tr:last-child { border-bottom: none; }
table.db-table tbody tr:nth-child(even) { background: var(--surface); }
table.db-table tbody tr:hover { background: var(--surface2); }
table.db-table tbody td {
padding: 7px 12px;
color: var(--text);
white-space: nowrap;
font-family: monospace;
font-size: 12px;
}
table.db-table tbody td.td-text {
font-family: inherit;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
table.db-table tbody td.td-dim { color: var(--text-mute); }
table.db-table tbody td.td-key { color: var(--blue-lt); }
/* PPV color tiers: green < 0.5, amber < 2.0, red ≥ 2.0 in/s */
.ppv-ok { color: var(--green-lt); font-weight: 600; }
.ppv-warn { color: var(--yellow); font-weight: 600; }
.ppv-high { color: var(--red); font-weight: 600; }
.ft-badge {
background: rgba(248,81,73,0.15);
border: 1px solid rgba(248,81,73,0.4);
border-radius: 4px;
color: var(--red);
font-family: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 1px 6px;
}
.ft-toggle-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-dim);
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
}
.ft-toggle-btn:hover { border-color: var(--red); color: var(--red); }
.ft-toggle-btn.flagged { border-color: var(--red); color: var(--red); background: rgba(248,81,73,0.1); }
.db-empty {
color: var(--text-mute);
font-size: 13px;
padding: 40px 0;
text-align: center;
}
/* Units tab cards */
.units-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
max-width: 900px;
}
.unit-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
cursor: pointer;
}
.unit-card:hover { border-color: var(--blue-lt); }
.unit-card .uc-serial {
font-size: 16px;
font-weight: 700;
font-family: monospace;
color: var(--blue-lt);
margin-bottom: 8px;
}
.unit-card .uc-stat {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.unit-card .uc-label { font-size: 11px; color: var(--text-mute); }
.unit-card .uc-val { font-size: 12px; color: var(--text); font-family: monospace; }
/* ── Section switcher ── */
.section-switcher {
display: flex;
gap: 3px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
padding: 3px;
}
.section-btn {
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
padding: 4px 14px;
transition: background 0.12s, color 0.12s;
background: none;
color: var(--text-dim);
white-space: nowrap;
}
.section-btn:hover { color: var(--text); }
.section-btn.active { background: var(--blue); color: #fff; }
/* ── Force-refresh toggle ── */
.force-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
cursor: pointer;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
user-select: none;
white-space: nowrap;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.force-toggle input { margin: 0; cursor: pointer; }
.force-toggle:hover { color: var(--text); }
.force-toggle.active {
background: rgba(248, 81, 73, 0.18);
border-color: #f85149;
color: #ff7b72;
}
.force-toggle .ft-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-mute);
}
.force-toggle.active .ft-dot { background: #f85149; box-shadow: 0 0 6px #f85149; }
/* ── Sidecar review modal ── */
.sc-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.55);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.sc-overlay.visible { display: flex; }
.sc-modal {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
width: min(720px, 92vw);
max-height: 88vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.sc-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.sc-header h3 {
margin: 0; font-size: 14px; font-weight: 600;
color: var(--text); font-family: monospace;
}
.sc-close {
background: none; border: none; cursor: pointer;
color: var(--text-mute); font-size: 18px; line-height: 1;
padding: 4px 8px; border-radius: 4px;
}
.sc-close:hover { background: var(--surface); color: var(--text); }
.sc-body {
flex: 1; overflow-y: auto;
padding: 16px 18px;
display: flex; flex-direction: column; gap: 14px;
}
.sc-section {
display: flex; flex-direction: column; gap: 6px;
}
.sc-section h4 {
margin: 0 0 4px;
font-size: 11px; font-weight: 600;
color: var(--text-mute); text-transform: uppercase;
letter-spacing: 0.6px;
}
.sc-grid {
display: grid;
grid-template-columns: 130px 1fr;
gap: 4px 12px;
font-size: 12px;
}
.sc-grid dt { color: var(--text-mute); }
.sc-grid dd { margin: 0; color: var(--text); font-family: monospace; word-break: break-all; }
.sc-row { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.sc-row label { color: var(--text-dim); }
.sc-row input[type="checkbox"] { cursor: pointer; }
.sc-row input[type="text"], .sc-body textarea {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
padding: 6px 9px;
font-size: 12px;
color: var(--text);
font-family: monospace;
}
.sc-body textarea {
width: 100%;
min-height: 80px;
resize: vertical;
font-family: inherit;
}
.sc-raw {
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
}
.sc-raw summary {
padding: 6px 10px;
cursor: pointer;
font-size: 11px;
color: var(--text-dim);
user-select: none;
}
.sc-raw pre {
margin: 0;
padding: 8px 12px;
max-height: 240px;
overflow: auto;
font-size: 11px;
color: var(--text);
border-top: 1px solid var(--border);
}
.sc-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--border);
}
.sc-status {
flex: 1; align-self: center;
font-size: 11px; color: var(--text-mute);
}
.sc-status.error { color: #f85149; }
.sc-status.ok { color: #56d364; }
table.db-table tbody tr.clickable { cursor: pointer; }
table.db-table tbody tr.clickable:hover { background: var(--surface2); }
/* ── Section containers ── */
#section-live, #section-db {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* Default to Database view on page load — most users are here to
browse stored events, not connect to a live unit. */
#section-live { display: none; }
/* ── Live connect bar (host/port/connect, live section only) ── */
#live-connect-bar {
background: var(--surface);
border-bottom: 1px solid var(--border2);
padding: 8px 18px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
flex-wrap: wrap;
}
#live-connect-bar label.hdr { color: var(--text-dim); font-size: 11px; }
#live-connect-bar input[type="text"],
#live-connect-bar input[type="number"] { font-size: 12px; padding: 5px 8px; }
#live-connect-bar #dev-host { width: 150px; }
#live-connect-bar #dev-port { width: 70px; }
#connect-btn { margin-left: auto; background: var(--green); color: #fff; }
#connect-btn:hover { background: var(--green-lt); }
</style>
</head>
<body>
<!-- ── Header ─────────────────────────────────────────────────────── -->
<header>
<div class="app-title">SFM <span>Seismograph Field Module</span></div>
<div class="hdr-sep"></div>
<div class="hdr-group">
<label class="hdr">API</label>
<input type="text" id="api-base" />
</div>
<div class="hdr-sep"></div>
<div class="section-switcher">
<button class="section-btn" onclick="switchSection('live')">Live Device</button>
<button class="section-btn active" onclick="switchSection('db')">Database</button>
</div>
<div class="hdr-sep"></div>
<label class="force-toggle" id="force-toggle"
title="Bypass server cache and dedup. Forces a fresh download from the device on every live request — useful when the device has been erased and the cache is showing stale events.">
<input type="checkbox" id="force-cb" onchange="onForceToggle()">
<span class="ft-dot"></span>
<span>Force refresh</span>
</label>
<div class="hdr-sep"></div>
<button id="mic-unit-toggle" class="section-btn"
onclick="_setMicUnit(_getMicUnit() === 'dBL' ? 'psi' : 'dBL')"
title="Toggle microphone display unit (dBL ↔ psi) for waveform plots. Affects all mic charts; persists across page loads.">
Mic: dBL
</button>
</header>
<!-- ════════════════════════════════════════════════════════════════
SECTION: Live Device
═══════════════════════════════════════════════════════════════════ -->
<div id="section-live">
<!-- ── Live connect bar ────────────────────────────────────────────── -->
<div id="live-connect-bar">
<label class="hdr">Host</label>
<input type="text" id="dev-host" placeholder="e.g. 63.43.212.232" />
<label class="hdr">Port</label>
<input type="number" id="dev-port" value="9034" />
<button class="btn" id="connect-btn" onclick="connectUnit()">Connect</button>
</div>
<!-- ── Device info bar ─────────────────────────────────────────────── -->
<div id="device-bar">
<div class="di-field">
<span class="di-label">Serial</span>
<span class="di-value" id="di-serial"></span>
</div>
<div class="di-field">
<span class="di-label">Firmware</span>
<span class="di-value" id="di-fw"></span>
</div>
<div class="di-field">
<span class="di-label">Sample rate</span>
<span class="di-value accent" id="di-sr"></span>
</div>
<div class="di-field">
<span class="di-label">Record time</span>
<span class="di-value" id="di-rt"></span>
</div>
<div class="di-field">
<span class="di-label">Trigger</span>
<span class="di-value" id="di-trig"></span>
</div>
<div class="di-field">
<span class="di-label">Events</span>
<span class="di-value accent" id="di-count"></span>
</div>
<div class="di-field" style="margin-left:12px">
<span class="di-label">Project</span>
<span class="di-value project-val" id="di-project"></span>
</div>
<div class="di-field">
<span class="di-label">Client</span>
<span class="di-value project-val" id="di-client"></span>
</div>
<div class="di-field">
<span class="di-label">Operator</span>
<span class="di-value project-val" id="di-operator"></span>
</div>
</div>
<!-- ── Monitor panel ───────────────────────────────────────────────── -->
<div id="monitor-panel">
<span class="mon-status-badge idle" id="mon-badge">IDLE</span>
<div class="mon-field">
<span class="mon-label">Battery</span>
<span class="mon-value" id="mon-battery"></span>
</div>
<div class="mon-field">
<span class="mon-label">Memory total</span>
<span class="mon-value" id="mon-mem-total"></span>
</div>
<div class="mon-field">
<span class="mon-label">Memory free</span>
<span class="mon-value" id="mon-mem-free"></span>
</div>
<div class="mon-spacer"></div>
<button class="btn" id="mon-refresh-btn" onclick="refreshMonitorStatus()" title="Refresh monitoring status">↻ Status</button>
<button class="btn" id="mon-start-btn" onclick="startMonitoring()" title="Start monitoring">▶ Start</button>
<button class="btn" id="mon-stop-btn" onclick="stopMonitoring()" title="Stop monitoring">■ Stop</button>
</div>
<!-- ── Status bar ─────────────────────────────────────────────────── -->
<div id="status-bar">Ready — enter device host and click Connect.</div>
<!-- ── Live tab bar ───────────────────────────────────────────────── -->
<div class="tab-bar" id="live-tab-bar">
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
<button class="tab-btn" data-tab="call-home" onclick="switchTab('call-home')">Call Home</button>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Device
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-device" class="tab-pane active">
<div style="padding:24px">
<div id="no-device-msg">Connect to a device to view its information.</div>
<div id="device-detail" style="display:none">
<div class="dev-grid" id="dev-cards"></div>
<div class="dev-section-title">Compliance Config</div>
<div class="dev-table" id="compliance-table"></div>
<div class="dev-section-title">Project Info</div>
<div class="dev-table" id="project-table"></div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Events
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-events" class="tab-pane" style="display:flex; flex-direction:column; overflow:hidden;">
<div class="event-toolbar">
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<button class="btn btn-ghost" id="save-btn" onclick="saveEventToDb()" disabled
title="Download the full waveform from the device and save it to the SFM database + waveform store. Honors the Force refresh toggle.">
💾 Save to DB
</button>
<button class="btn btn-ghost" id="download-btn" onclick="downloadEventFile()" disabled
title="Download the Blastware-format event file to your computer (also saves it to the server's database + store).">
⬇ Download
</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>
<div class="event-chips" id="event-chips"></div>
</div>
<div class="peaks-bar" id="peaks-bar">
<div class="pk"><div class="pk-label">Tran</div><div class="pk-value pk-tran" id="pk-tran"></div></div>
<div class="pk"><div class="pk-label">Vert</div><div class="pk-value pk-vert" id="pk-vert"></div></div>
<div class="pk"><div class="pk-label">Long</div><div class="pk-value pk-long" id="pk-long"></div></div>
<div class="pk"><div class="pk-label">MicL</div><div class="pk-value pk-mic" id="pk-mic"></div></div>
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs"></div></div>
</div>
<div id="waveform-area" style="flex:1; overflow-y:auto;">
<div id="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<p>No waveform loaded</p>
</div>
<div id="charts" style="display:none"></div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Config
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-config" class="tab-pane">
<div class="cfg-grid">
<!-- Recording parameters -->
<div class="cfg-section">
<div class="cfg-section-title">Recording</div>
<div class="cfg-field">
<label>Recording Mode</label>
<select id="cfg-recording-mode">
<option value="">— unchanged —</option>
<option value="0">Single Shot</option>
<option value="1">Continuous</option>
<option value="3">Histogram</option>
<option value="4">Histogram + Continuous</option>
</select>
</div>
<div class="cfg-field">
<label>Sample Rate</label>
<select id="cfg-sample-rate">
<option value="">— unchanged —</option>
<option value="1024">1024 sps (standard compliance)</option>
<option value="2048">2048 sps</option>
<option value="4096">4096 sps (high-speed)</option>
</select>
</div>
<div class="cfg-field">
<label>Histogram Interval</label>
<select id="cfg-histogram-interval">
<option value="">— unchanged —</option>
<option value="2">2 seconds</option>
<option value="5">5 seconds</option>
<option value="15">15 seconds</option>
<option value="60">1 minute</option>
<option value="300">5 minutes</option>
<option value="900">15 minutes</option>
</select>
<div class="hint">Only active in Histogram / Histogram + Continuous mode</div>
</div>
<div class="cfg-field">
<label>Record Time (seconds)</label>
<input type="number" id="cfg-record-time" step="0.5" min="0.5" max="60" placeholder="e.g. 3.0" />
<div class="hint">Waveform capture duration per trigger event</div>
</div>
<div class="cfg-field">
<label>Trigger Level — Geo (in/s)</label>
<input type="number" id="cfg-trigger" step="0.01" min="0.01" placeholder="e.g. 0.50" />
<div class="hint">Recording starts when any geo channel exceeds this level</div>
</div>
<div class="cfg-field">
<label>Alarm Level — Geo (in/s)</label>
<input type="number" id="cfg-alarm" step="0.01" min="0.01" placeholder="e.g. 1.00" />
<div class="hint">Alarm flagged when any geo channel exceeds this level</div>
</div>
<div class="cfg-field">
<label>Maximum Range — Geo</label>
<select id="cfg-geo-range">
<option value="">— unchanged —</option>
<option value="0">Normal — 10.000 in/s</option>
<option value="1">Sensitive — 1.250 in/s</option>
</select>
<div class="hint">Geophone sensitivity (applies to Tran / Vert / Long channels)</div>
</div>
</div>
<!-- Project / operator strings -->
<div class="cfg-section">
<div class="cfg-section-title">Project Info</div>
<div class="cfg-field">
<label>Project</label>
<input type="text" id="cfg-project" maxlength="41" placeholder="Project description" />
</div>
<div class="cfg-field">
<label>Client</label>
<input type="text" id="cfg-client" maxlength="41" placeholder="Client / company name" />
</div>
<div class="cfg-field">
<label>Operator</label>
<input type="text" id="cfg-operator" maxlength="41" placeholder="Technician name" />
</div>
<div class="cfg-field">
<label>Sensor Location</label>
<input type="text" id="cfg-seis-loc" maxlength="41" placeholder="e.g. South Abutment" />
</div>
<div class="cfg-field">
<label>Notes</label>
<input type="text" id="cfg-notes" maxlength="41" placeholder="Extended notes" />
</div>
</div>
</div>
<div class="cfg-actions">
<button class="btn btn-ghost" id="cfg-read-btn" onclick="readConfig()" disabled>Read from Device</button>
<button class="btn btn-success" id="cfg-write-btn" onclick="writeConfig()" disabled>Write to Device</button>
<button class="btn btn-ghost" onclick="clearConfigForm()">Clear Form</button>
<span id="cfg-status"></span>
</div>
</div><!-- end #tab-config -->
<!-- ════════════════════════════════════════════════════════════════
TAB: Call Home
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-call-home" class="tab-pane">
<div class="cfg-grid">
<!-- Enable / dial -->
<div class="cfg-section">
<div class="cfg-section-title">Auto Call Home</div>
<div class="cfg-field">
<label>Enable Auto Call Home</label>
<select id="ch-enabled">
<option value="">— unchanged —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div class="cfg-field">
<label>Dial String</label>
<input type="text" id="ch-dial-string" disabled placeholder="Read-only (e.g. RADIO RING)" />
<div class="hint">Read from device — not writable via this interface</div>
</div>
<div class="cfg-section-title" style="margin-top:16px">When to Call</div>
<div class="cfg-field">
<label>After Event Recorded</label>
<select id="ch-after-event">
<option value="">— unchanged —</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="cfg-field">
<label>At Specified Times</label>
<select id="ch-at-times">
<option value="">— unchanged —</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<!-- Scheduled call times -->
<div class="cfg-section">
<div class="cfg-section-title">Scheduled Call Times</div>
<div class="cfg-field">
<label>Time Slot 1</label>
<div style="display:flex;gap:8px;align-items:center">
<select id="ch-t1-enabled" style="width:120px">
<option value="">— enable —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
<input type="number" id="ch-t1-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
<span>:</span>
<input type="number" id="ch-t1-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
</div>
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
</div>
<div class="cfg-field">
<label>Time Slot 2</label>
<div style="display:flex;gap:8px;align-items:center">
<select id="ch-t2-enabled" style="width:120px">
<option value="">— enable —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
<input type="number" id="ch-t2-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
<span>:</span>
<input type="number" id="ch-t2-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
</div>
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
</div>
<div class="cfg-section-title" style="margin-top:16px">Retry Settings (read-only)</div>
<div class="cfg-field">
<label>Number of Retries</label>
<input type="text" id="ch-num-retries" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Time Between Retries (s)</label>
<input type="text" id="ch-retry-gap" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Wait for Connection (s)</label>
<input type="text" id="ch-wait-conn" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Warm-up Time (s)</label>
<input type="text" id="ch-warmup" disabled placeholder="—" />
</div>
</div>
</div>
<div class="cfg-actions">
<button class="btn btn-ghost" id="ch-read-btn" onclick="readCallHome()" disabled>Read from Device</button>
<button class="btn btn-success" id="ch-write-btn" onclick="writeCallHome()" disabled>Write to Device</button>
<button class="btn btn-ghost" onclick="clearCallHomeForm()">Clear Form</button>
<span id="ch-status"></span>
</div>
</div><!-- end #tab-call-home -->
</div><!-- end #section-live -->
<!-- ════════════════════════════════════════════════════════════════
SECTION: Database
═══════════════════════════════════════════════════════════════════ -->
<div id="section-db">
<!-- ── Database tab bar ──────────────────────────────────────────── -->
<div class="tab-bar" id="db-tab-bar">
<button class="tab-btn active" data-tab="history" onclick="switchTab('history')">History</button>
<button class="tab-btn" data-tab="units" onclick="switchTab('units')">Units</button>
<button class="tab-btn" data-tab="monlog" onclick="switchTab('monlog')">Monitor Log</button>
<button class="tab-btn" data-tab="sessions" onclick="switchTab('sessions')">Sessions</button>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: History (events from DB)
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-history" class="tab-pane db-tab-pane">
<div class="db-toolbar">
<label>Serial</label>
<select id="hist-serial-filter" onchange="loadHistory()">
<option value="">All units</option>
</select>
<label>From</label>
<input type="date" class="date-input" id="hist-from" onchange="loadHistory()" />
<label>To</label>
<input type="date" class="date-input" id="hist-to" onchange="loadHistory()" />
<label style="display:flex;align-items:center;gap:5px;cursor:pointer;">
<input type="checkbox" id="hist-hide-ft" onchange="loadHistory()" />
Hide false triggers
</label>
<div class="db-toolbar-spacer"></div>
<button class="btn btn-ghost" onclick="loadHistory()">↻ Refresh</button>
<span class="db-count-badge" id="hist-count"></span>
</div>
<div class="db-scroll" id="hist-scroll">
<div class="db-empty" id="hist-empty" style="display:none">No events found.</div>
<div class="db-table-wrap" id="hist-table-wrap" style="display:none">
<table class="db-table" id="hist-table">
<thead>
<tr id="hist-header-row">
<th data-sort="timestamp">Timestamp <span class="sort-arrow"></span></th>
<th data-sort="serial">Serial <span class="sort-arrow"></span></th>
<th data-sort="tran_ppv">Tran (in/s) <span class="sort-arrow"></span></th>
<th data-sort="vert_ppv">Vert (in/s) <span class="sort-arrow"></span></th>
<th data-sort="long_ppv">Long (in/s) <span class="sort-arrow"></span></th>
<th data-sort="peak_vector_sum">PVS (in/s) <span class="sort-arrow"></span></th>
<th data-sort="mic_ppv">Mic (dBL) <span class="sort-arrow"></span></th>
<th data-sort="project">Project <span class="sort-arrow"></span></th>
<th data-sort="client">Client <span class="sort-arrow"></span></th>
<th data-sort="record_type">Type <span class="sort-arrow"></span></th>
<th data-sort="waveform_key">Key <span class="sort-arrow"></span></th>
<th></th>
</tr>
</thead>
<tbody id="hist-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Units
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-units" class="tab-pane db-tab-pane">
<div class="db-toolbar">
<div class="db-toolbar-spacer"></div>
<button class="btn btn-ghost" onclick="loadUnits()">↻ Refresh</button>
<span class="db-count-badge" id="units-count"></span>
</div>
<div class="db-scroll">
<div class="db-empty" id="units-empty" style="display:none">No units in database yet.</div>
<div class="units-grid" id="units-grid"></div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Monitor Log
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-monlog" class="tab-pane db-tab-pane">
<div class="db-toolbar">
<label>Serial</label>
<select id="monlog-serial-filter" onchange="loadMonitorLog()">
<option value="">All units</option>
</select>
<label>From</label>
<input type="date" class="date-input" id="monlog-from" onchange="loadMonitorLog()" />
<label>To</label>
<input type="date" class="date-input" id="monlog-to" onchange="loadMonitorLog()" />
<div class="db-toolbar-spacer"></div>
<button class="btn btn-ghost" onclick="loadMonitorLog()">↻ Refresh</button>
<span class="db-count-badge" id="monlog-count"></span>
</div>
<div class="db-scroll" id="monlog-scroll">
<div class="db-empty" id="monlog-empty" style="display:none">No monitor log entries found.</div>
<div class="db-table-wrap" id="monlog-table-wrap" style="display:none">
<table class="db-table" id="monlog-table">
<thead>
<tr>
<th>Start Time</th>
<th>Stop Time</th>
<th>Duration</th>
<th>Serial</th>
<th>Geo Threshold</th>
<th>Key</th>
</tr>
</thead>
<tbody id="monlog-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Sessions
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-sessions" class="tab-pane db-tab-pane">
<div class="db-toolbar">
<label>Serial</label>
<select id="sess-serial-filter" onchange="loadSessions()">
<option value="">All units</option>
</select>
<div class="db-toolbar-spacer"></div>
<button class="btn btn-ghost" onclick="loadSessions()">↻ Refresh</button>
<span class="db-count-badge" id="sess-count"></span>
</div>
<div class="db-scroll" id="sess-scroll">
<div class="db-empty" id="sess-empty" style="display:none">No ACH sessions recorded yet.</div>
<div class="db-table-wrap" id="sess-table-wrap" style="display:none">
<table class="db-table" id="sess-table">
<thead>
<tr>
<th>Session Time</th>
<th>Serial</th>
<th>Peer</th>
<th>Events DL'd</th>
<th>Monitor Entries</th>
<th>Duration (s)</th>
</tr>
</thead>
<tbody id="sess-tbody"></tbody>
</table>
</div>
</div>
</div><!-- end #tab-sessions -->
</div><!-- end #section-db -->
<!-- ── Script ─────────────────────────────────────────────────────── -->
<script>
'use strict';
// ── State ──────────────────────────────────────────────────────────────────────
let unitInfo = null;
let eventList = [];
let currentEvent = 0;
let charts = {};
let geoAdcScale = 6.206;
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', MicL:'#bc8cff' };
// ── Helpers ────────────────────────────────────────────────────────────────────
function api() { return document.getElementById('api-base').value.replace(/\/$/, ''); }
function devHost() { return document.getElementById('dev-host').value.trim(); }
function devPort() { return document.getElementById('dev-port').value; }
function qs(sel, val) { const el = document.getElementById(sel); if (val !== undefined) el.value = val; return el; }
function setStatus(msg, cls = '') {
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = cls;
bar.textContent = msg;
}
function addPill(text) {
const bar = document.getElementById('status-bar');
const p = document.createElement('span');
p.className = 'meta-pill';
p.textContent = text;
bar.appendChild(p);
}
function setCfgStatus(msg, cls = '') {
const el = document.getElementById('cfg-status');
el.textContent = msg;
el.className = cls;
}
// "Force refresh" override — when enabled, every live-device request is
// sent with ?force=true so the server bypasses its in-memory + persistent
// caches and re-reads from the device. Manual escape hatch for cases where
// the cache has gone stale (e.g. post-erase key reuse — see ach_server.py
// and sfm/cache.py for the eviction logic).
let forceRefresh = false;
function onForceToggle() {
forceRefresh = document.getElementById('force-cb').checked;
document.getElementById('force-toggle').classList.toggle('active', forceRefresh);
}
function deviceParams() {
const base = `host=${encodeURIComponent(devHost())}&tcp_port=${devPort()}`;
return forceRefresh ? `${base}&force=true` : base;
}
// ── Section switching ─────────────────────────────────────────────────────────
// Default to Database — most users land here to browse stored events.
// Live Device is opt-in (click the tab to talk to a unit).
let currentSection = 'db';
function switchSection(name) {
currentSection = name;
document.querySelectorAll('.section-btn').forEach(b => {
b.classList.toggle('active', b.textContent.toLowerCase().startsWith(name === 'live' ? 'live' : 'data'));
});
document.getElementById('section-live').style.display = name === 'live' ? 'flex' : 'none';
document.getElementById('section-db').style.display = name === 'db' ? 'flex' : 'none';
// Auto-load DB section on first visit
if (name === 'db') {
if (!histLoaded) loadHistory();
if (!unitsLoaded) loadUnits();
}
}
// ── Tab switching ──────────────────────────────────────────────────────────────
function switchTab(name) {
// Activate the matching tab button within its own tab bar
const btn = document.querySelector(`.tab-btn[data-tab="${name}"]`);
if (btn) {
btn.closest('.tab-bar').querySelectorAll('.tab-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
// Hide all panes in both sections, then show the target
document.querySelectorAll('.tab-pane').forEach(p => {
p.classList.remove('active');
if (p.style.display === 'flex') p.style.display = 'none';
});
const pane = document.getElementById(`tab-${name}`);
if (!pane) return;
const needsFlex = pane.id === 'tab-events' || pane.classList.contains('db-tab-pane');
if (needsFlex) {
pane.style.display = 'flex';
} else {
pane.classList.add('active');
}
// Auto-load DB tabs on first switch
if (name === 'history') { if (!histLoaded) loadHistory(); }
if (name === 'units') { if (!unitsLoaded) loadUnits(); }
if (name === 'monlog') { if (!monlogLoaded) loadMonitorLog(); }
if (name === 'sessions') { if (!sessLoaded) loadSessions(); }
}
// ── Connect ────────────────────────────────────────────────────────────────────
async function connectUnit() {
if (!devHost()) { setStatus('Enter a device host first.', 'error'); return; }
const btn = document.getElementById('connect-btn');
btn.disabled = true;
btn.textContent = 'Connecting…';
setStatus('Connecting — reading device info…', 'loading');
try {
const r = await fetch(`${api()}/device/info?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
unitInfo = await r.json();
} catch (e) {
setStatus(`Connection failed: ${e.message}`, 'error');
btn.disabled = false; btn.textContent = 'Connect'; return;
}
setStatus('Fetching event list…', 'loading');
try {
const r = await fetch(`${api()}/device/events?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const evData = await r.json();
eventList = evData.events || [];
// Merge compliance from /device/events response (it re-reads it)
if (evData.device) unitInfo = { ...unitInfo, ...evData.device };
} catch (e) {
setStatus(`Event fetch failed: ${e.message}`, 'error');
btn.disabled = false; btn.textContent = 'Reconnect'; return;
}
populateDeviceBar();
populateDeviceTab();
populateEventChips();
populateConfigFromDeviceInfo();
document.getElementById('device-bar').style.display = 'flex';
document.getElementById('monitor-panel').style.display = 'flex';
document.getElementById('load-btn').disabled = eventList.length === 0;
document.getElementById('save-btn').disabled = eventList.length === 0;
document.getElementById('download-btn').disabled = eventList.length === 0;
document.getElementById('prev-btn').disabled = true;
document.getElementById('next-btn').disabled = eventList.length <= 1;
document.getElementById('cfg-read-btn').disabled = false;
document.getElementById('cfg-write-btn').disabled = false;
document.getElementById('ch-read-btn').disabled = false;
document.getElementById('ch-write-btn').disabled = false;
btn.disabled = false; btn.textContent = 'Reconnect';
setStatus(`Connected — ${eventList.length} event${eventList.length !== 1 ? 's' : ''} stored.`, 'ok');
// Fetch monitor status in background (non-blocking)
refreshMonitorStatus().catch(() => {});
const cc = unitInfo.compliance_config;
if (cc) {
if (cc.sample_rate) addPill(`${cc.sample_rate} sps`);
if (cc.trigger_level_geo != null) addPill(`trig ${cc.trigger_level_geo.toFixed(3)} in/s`);
}
}
// ── Device bar ─────────────────────────────────────────────────────────────────
function populateDeviceBar() {
qs('di-serial').textContent = unitInfo.serial || '—';
qs('di-fw').textContent = unitInfo.firmware_version || '—';
const cc = unitInfo.compliance_config || {};
qs('di-sr').textContent = cc.sample_rate ? `${cc.sample_rate} sps` : '—';
qs('di-rt').textContent = cc.record_time != null ? `${cc.record_time.toFixed(1)} s` : '—';
qs('di-trig').textContent = cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(3)} in/s` : '—';
qs('di-count').textContent = eventList.length;
qs('di-project').textContent = cc.project || '—';
qs('di-client').textContent = cc.client || '—';
qs('di-operator').textContent = cc.operator || '—';
geoAdcScale = cc.geo_adc_scale ?? 6.206;
}
// ── Monitoring ─────────────────────────────────────────────────────────────────
async function refreshMonitorStatus() {
if (!devHost()) return null;
try {
const r = await fetch(`${api()}/device/monitor/status?${deviceParams()}`);
if (!r.ok) return null;
const s = await r.json();
updateMonitorPanel(s);
return s;
} catch (_) { return null; }
}
function updateMonitorPanel(s) {
const panel = document.getElementById('monitor-panel');
const badge = document.getElementById('mon-badge');
const batEl = document.getElementById('mon-battery');
const memTEl = document.getElementById('mon-mem-total');
const memFEl = document.getElementById('mon-mem-free');
const startB = document.getElementById('mon-start-btn');
const stopB = document.getElementById('mon-stop-btn');
if (s.is_monitoring) {
badge.textContent = 'MONITORING';
badge.className = 'mon-status-badge monitoring';
panel.className = 'monitoring';
startB.disabled = true;
stopB.disabled = false;
} else {
badge.textContent = 'IDLE';
badge.className = 'mon-status-badge idle';
panel.className = 'idle';
startB.disabled = false;
stopB.disabled = true;
}
// Battery and memory are available in both states — update if present,
// keep previous value if this was an optimistic update with no real data.
if (s.battery_v != null) batEl.textContent = `${s.battery_v.toFixed(2)} V`;
if (s.memory_total_kb != null) memTEl.textContent = `${s.memory_total_kb} KB`;
if (s.memory_free_kb != null) memFEl.textContent = `${s.memory_free_kb} KB`;
}
async function startMonitoring() {
if (!devHost()) return;
const btn = document.getElementById('mon-start-btn');
btn.disabled = true; btn.textContent = '…';
setStatus('Starting monitoring…', 'loading');
try {
const r = await fetch(`${api()}/device/monitor/start?${deviceParams()}`, { method: 'POST' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
// Optimistically show MONITORING immediately. The unit may run a ~40s on-device
// sensor check before fully entering monitor mode. We poll status every 5s for
// up to 60s, updating the badge when is_monitoring flips to true.
updateMonitorPanel({ is_monitoring: true });
setStatus('Monitoring started — sensor check in progress (~40s)…', 'loading');
btn.textContent = '▶ Start';
_pollMonitorConfirm(0);
} catch (e) {
setStatus(`Start monitoring failed: ${e.message}`, 'error');
btn.disabled = false;
btn.textContent = '▶ Start';
}
}
async function _pollMonitorConfirm(attempt) {
// Poll /device/monitor/status every 5s for up to 60s after startMonitoring().
// Updates the panel on each successful poll. Resolves once is_monitoring is true
// or after 12 attempts (60s), whichever comes first.
const MAX_ATTEMPTS = 12;
const INTERVAL_MS = 5000;
if (attempt >= MAX_ATTEMPTS) {
const s = await refreshMonitorStatus();
if (!s || !s.is_monitoring) {
setStatus('Warning: unit did not confirm monitoring state after 60s. Check device.', 'error');
} else {
setStatus('Monitoring active.', 'ok');
}
return;
}
await new Promise(res => setTimeout(res, INTERVAL_MS));
const s = await refreshMonitorStatus();
if (s && s.is_monitoring) {
setStatus('Monitoring active.', 'ok');
} else {
const elapsed = (attempt + 1) * 5;
setStatus(`Sensor check in progress… (${elapsed}s elapsed)`, 'loading');
_pollMonitorConfirm(attempt + 1);
}
}
async function stopMonitoring() {
if (!devHost()) return;
const btn = document.getElementById('mon-stop-btn');
btn.disabled = true; btn.textContent = '…';
setStatus('Stopping monitoring…', 'loading');
try {
const r = await fetch(`${api()}/device/monitor/stop?${deviceParams()}`, { method: 'POST' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
setStatus('Monitoring stopped.', 'ok');
await refreshMonitorStatus();
} catch (e) {
setStatus(`Stop monitoring failed: ${e.message}`, 'error');
btn.disabled = false;
}
btn.textContent = '■ Stop';
}
// ── Device tab ─────────────────────────────────────────────────────────────────
function populateDeviceTab() {
document.getElementById('no-device-msg').style.display = 'none';
document.getElementById('device-detail').style.display = 'block';
// Identity cards
const cards = document.getElementById('dev-cards');
cards.innerHTML = '';
const cardData = [
{ label:'Serial Number', value: unitInfo.serial || '—' },
{ label:'Firmware', value: unitInfo.firmware_version || '—' },
{ label:'DSP', value: unitInfo.dsp_version || '—' },
{ label:'Model', value: unitInfo.model || '—' },
{ label:'Manufacturer', value: unitInfo.manufacturer || '—' },
{ label:'Stored Events', value: eventList.length },
];
for (const {label, value} of cardData) {
const c = document.createElement('div');
c.className = 'dev-card';
c.innerHTML = `<div class="dev-card-label">${label}</div><div class="dev-card-value">${value}</div>`;
cards.appendChild(c);
}
// Compliance table
const cc = unitInfo.compliance_config || {};
const RECORDING_MODE_LABELS = {0: 'Single Shot', 1: 'Continuous', 3: 'Histogram', 4: 'Histogram + Continuous'};
const complianceRows = [
['Recording Mode', cc.recording_mode != null ? (RECORDING_MODE_LABELS[cc.recording_mode] || `0x${cc.recording_mode.toString(16).padStart(2,'0')}`) : '—'],
['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'],
['Histogram Interval', cc.histogram_interval_sec != null ? (() => { const s = cc.histogram_interval_sec; return s < 60 ? `${s}s` : `${s/60}m`; })() : '—'],
['Record Time', cc.record_time != null ? `${cc.record_time.toFixed(2)} s` : '—'],
['Trigger Level (geo)', cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(4)} in/s` : '—'],
['Alarm Level (geo)', cc.alarm_level_geo != null ? `${cc.alarm_level_geo.toFixed(4)} in/s` : '—'],
['Max Range (geo)', cc.geo_range != null ? (cc.geo_range === 0 ? 'Normal — 10.000 in/s' : cc.geo_range === 1 ? 'Sensitive — 1.250 in/s' : `0x${cc.geo_range.toString(16).padStart(2,'0')}`) : '—'],
['ADC Scale Factor (geo)', cc.geo_adc_scale != null ? `${cc.geo_adc_scale.toFixed(4)} in/s` : '—'],
['Setup Name', cc.setup_name || '—'],
];
renderTable('compliance-table', complianceRows);
// Project table
const projectRows = [
['Project', cc.project || '—'],
['Client', cc.client || '—'],
['Operator', cc.operator || '—'],
['Sensor Location', cc.sensor_location || '—'],
['Notes', cc.notes || '—'],
];
renderTable('project-table', projectRows);
}
function renderTable(id, rows) {
const el = document.getElementById(id);
el.innerHTML = '';
for (const [label, value] of rows) {
const row = document.createElement('div');
row.className = 'dev-table-row';
row.innerHTML = `<div class="dtr-label">${label}</div><div class="dtr-value">${value}</div>`;
el.appendChild(row);
}
}
// ── Config form ────────────────────────────────────────────────────────────────
function populateConfigFromDeviceInfo() {
if (!unitInfo) return;
const cc = unitInfo.compliance_config || {};
if (cc.recording_mode != null) qs('cfg-recording-mode', String(cc.recording_mode));
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
if (cc.histogram_interval_sec != null) qs('cfg-histogram-interval', String(cc.histogram_interval_sec));
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
if (cc.geo_range != null) qs('cfg-geo-range', String(cc.geo_range));
if (cc.project) qs('cfg-project', cc.project);
if (cc.client) qs('cfg-client', cc.client);
if (cc.operator) qs('cfg-operator', cc.operator);
if (cc.sensor_location) qs('cfg-seis-loc', cc.sensor_location);
if (cc.notes) qs('cfg-notes', cc.notes);
}
function clearConfigForm() {
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm',
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes',
'cfg-recording-mode','cfg-histogram-interval','cfg-geo-range']
.forEach(id => { const el = qs(id); el.tagName === 'SELECT' ? el.selectedIndex = 0 : el.value = ''; });
setCfgStatus('');
}
async function readConfig() {
if (!devHost()) { setCfgStatus('Not connected.', 'error'); return; }
setCfgStatus('Reading config from device…');
document.getElementById('cfg-read-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/info?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
unitInfo = await r.json();
populateConfigFromDeviceInfo();
populateDeviceBar();
populateDeviceTab();
setCfgStatus('Config loaded from device.', 'ok');
} catch(e) {
setCfgStatus(`Read failed: ${e.message}`, 'error');
} finally {
document.getElementById('cfg-read-btn').disabled = false;
}
}
async function writeConfig() {
if (!devHost()) { setCfgStatus('Not connected.', 'error'); return; }
// Build body — only include fields that have values
const body = {};
const rm = qs('cfg-recording-mode').value;
if (rm !== '') body.recording_mode = parseInt(rm, 10);
const sr = qs('cfg-sample-rate').value;
if (sr) body.sample_rate = parseInt(sr, 10);
const hi = qs('cfg-histogram-interval').value;
if (hi !== '') body.histogram_interval_sec = parseInt(hi, 10);
const rt = qs('cfg-record-time').value;
if (rt) body.record_time = parseFloat(rt);
const trig = qs('cfg-trigger').value;
if (trig) body.trigger_level_geo = parseFloat(trig);
const alarm = qs('cfg-alarm').value;
if (alarm) body.alarm_level_geo = parseFloat(alarm);
const gr = qs('cfg-geo-range').value;
if (gr !== '') body.geo_range = parseInt(gr, 10);
const proj = qs('cfg-project').value.trim();
if (proj) body.project = proj;
const cli = qs('cfg-client').value.trim();
if (cli) body.client_name = cli;
const op = qs('cfg-operator').value.trim();
if (op) body.operator = op;
const sl = qs('cfg-seis-loc').value.trim();
if (sl) body.seis_loc = sl;
const notes = qs('cfg-notes').value.trim();
if (notes) body.notes = notes;
if (Object.keys(body).length === 0) {
setCfgStatus('No fields to write — fill in at least one field.', 'error');
return;
}
const fieldsStr = Object.keys(body).join(', ');
setCfgStatus(`Writing ${Object.keys(body).length} field(s)…`);
document.getElementById('cfg-write-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/config?${deviceParams()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const result = await r.json();
setCfgStatus(`Written: ${fieldsStr}`, 'ok');
// Re-read device info so Device tab and bar refresh
await readConfig();
} catch(e) {
setCfgStatus(`Write failed: ${e.message}`, 'error');
} finally {
document.getElementById('cfg-write-btn').disabled = false;
}
}
// ── Call Home form ─────────────────────────────────────────────────────────────
function setChStatus(msg, type) {
const el = document.getElementById('ch-status');
el.textContent = msg;
el.style.color = type === 'ok' ? '#4caf50' : type === 'error' ? '#f44336' : '#aaa';
}
function populateCallHomeForm(ch) {
if (!ch) return;
const qs2 = id => document.getElementById(id);
// Read-only display fields
if (ch.dial_string != null) qs2('ch-dial-string').value = ch.dial_string || '';
if (ch.num_retries != null) qs2('ch-num-retries').value = ch.num_retries;
if (ch.time_between_retries_sec != null) qs2('ch-retry-gap').value = ch.time_between_retries_sec;
if (ch.wait_for_connection_sec != null) qs2('ch-wait-conn').value = ch.wait_for_connection_sec;
if (ch.warm_up_time_sec != null) qs2('ch-warmup').value = ch.warm_up_time_sec;
// Editable select/input fields (use "" for "unchanged" state when value is null)
function setBool(id, val) {
if (val != null) document.getElementById(id).value = val ? 'true' : 'false';
}
setBool('ch-enabled', ch.auto_call_home_enabled);
setBool('ch-after-event', ch.after_event_recorded);
setBool('ch-at-times', ch.at_specified_times);
setBool('ch-t1-enabled', ch.time1_enabled);
setBool('ch-t2-enabled', ch.time2_enabled);
if (ch.time1_hour != null) qs2('ch-t1-hour').value = ch.time1_hour;
if (ch.time1_min != null) qs2('ch-t1-min').value = ch.time1_min;
if (ch.time2_hour != null) qs2('ch-t2-hour').value = ch.time2_hour;
if (ch.time2_min != null) qs2('ch-t2-min').value = ch.time2_min;
}
function clearCallHomeForm() {
['ch-enabled','ch-after-event','ch-at-times','ch-t1-enabled','ch-t2-enabled']
.forEach(id => { document.getElementById(id).selectedIndex = 0; });
['ch-t1-hour','ch-t1-min','ch-t2-hour','ch-t2-min']
.forEach(id => { document.getElementById(id).value = ''; });
// Keep read-only display fields but clear them too
['ch-dial-string','ch-num-retries','ch-retry-gap','ch-wait-conn','ch-warmup']
.forEach(id => { document.getElementById(id).value = ''; });
setChStatus('');
}
async function readCallHome() {
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
setChStatus('Reading call home config from device…');
document.getElementById('ch-read-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const ch = await r.json();
populateCallHomeForm(ch);
setChStatus('Call home config loaded from device.', 'ok');
} catch(e) {
setChStatus(`Read failed: ${e.message}`, 'error');
} finally {
document.getElementById('ch-read-btn').disabled = false;
}
}
async function writeCallHome() {
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
// Build body — only include fields that have values
const body = {};
function getBool(id) {
const v = document.getElementById(id).value;
return v === '' ? null : v === 'true';
}
function getIntField(id) {
const v = document.getElementById(id).value.trim();
return v === '' ? null : parseInt(v, 10);
}
const en = getBool('ch-enabled');
if (en !== null) body.auto_call_home_enabled = en;
const ae = getBool('ch-after-event');
if (ae !== null) body.after_event_recorded = ae;
const at = getBool('ch-at-times');
if (at !== null) body.at_specified_times = at;
const t1e = getBool('ch-t1-enabled');
if (t1e !== null) body.time1_enabled = t1e;
const t1h = getIntField('ch-t1-hour');
if (t1h !== null) body.time1_hour = t1h;
const t1m = getIntField('ch-t1-min');
if (t1m !== null) body.time1_min = t1m;
const t2e = getBool('ch-t2-enabled');
if (t2e !== null) body.time2_enabled = t2e;
const t2h = getIntField('ch-t2-hour');
if (t2h !== null) body.time2_hour = t2h;
const t2m = getIntField('ch-t2-min');
if (t2m !== null) body.time2_min = t2m;
if (Object.keys(body).length === 0) {
setChStatus('No fields to write — change at least one field.', 'error');
return;
}
// Warn about value 3 in hour/min fields
const hourMinFields = [body.time1_hour, body.time1_min, body.time2_hour, body.time2_min];
if (hourMinFields.some(v => v === 3)) {
setChStatus('Error: value 3 in hour/minute fields is not supported (DLE protocol limitation).', 'error');
return;
}
const fieldsStr = Object.keys(body).join(', ');
setChStatus(`Writing ${Object.keys(body).length} field(s)…`);
document.getElementById('ch-write-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
setChStatus(`Written: ${fieldsStr}`, 'ok');
// Re-read to confirm changes
await readCallHome();
} catch(e) {
setChStatus(`Write failed: ${e.message}`, 'error');
} finally {
document.getElementById('ch-write-btn').disabled = false;
}
}
// ── Events ─────────────────────────────────────────────────────────────────────
function populateEventChips() {
const el = document.getElementById('event-chips');
el.innerHTML = '';
eventList.forEach((ev, i) => {
const chip = document.createElement('button');
chip.className = 'event-chip' + (i === 0 ? ' active' : '');
chip.textContent = ev.timestamp?.display ?? `Event ${ev.index}`;
chip.title = ev.record_type || '';
chip.onclick = () => selectEvent(i);
el.appendChild(chip);
});
// Show peak values from the event list header (no waveform needed)
if (eventList.length > 0) updatePeaksBar(eventList[0]);
}
function selectEvent(idx) {
currentEvent = idx;
document.querySelectorAll('.event-chip').forEach((c, i) => c.classList.toggle('active', i === idx));
document.getElementById('prev-btn').disabled = idx <= 0;
document.getElementById('next-btn').disabled = idx >= eventList.length - 1;
if (eventList[idx]) updatePeaksBar(eventList[idx]);
loadWaveform();
}
function stepEvent(delta) {
const next = Math.max(0, Math.min(eventList.length - 1, currentEvent + delta));
selectEvent(next);
}
function updatePeaksBar(ev) {
const pv = ev?.peak_values;
const bar = document.getElementById('peaks-bar');
if (!pv) { bar.classList.remove('visible'); return; }
bar.classList.add('visible');
qs('pk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
qs('pk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
qs('pk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
qs('pk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
qs('pk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
}
async function loadWaveform() {
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
const idx = currentEvent;
document.getElementById('load-btn').disabled = true;
setStatus('Fetching waveform…', 'loading');
let data;
try {
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch(e) {
setStatus(`Waveform error: ${e.message}`, 'error');
document.getElementById('load-btn').disabled = false;
return;
}
renderWaveform(data);
document.getElementById('load-btn').disabled = false;
}
// ── Persist current event to the SFM database + waveform store ──────────────
//
// Calls /device/event/{idx}/blastware_file, which on the server side:
// 1. Downloads the full waveform from the device (5A bulk stream)
// 2. Writes the Blastware-format event file into <db_dir>/waveforms/<serial>/
// 3. Writes the .a5.pkl sidecar next to it (so the file can be regenerated)
// 4. Upserts a row into seismo_relay.db `events` table (dedup'd on serial+timestamp)
//
// We discard the response body — the side effects are what we want. The
// filename comes back in the Content-Disposition header for confirmation.
async function saveEventToDb() {
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
const idx = currentEvent;
const btn = document.getElementById('save-btn');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = '⏳ Saving…';
setStatus(`Downloading event #${idx} and saving to DB…`, 'loading');
try {
const r = await fetch(`${api()}/device/event/${idx}/blastware_file?${deviceParams()}`);
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText);
}
// Pull the body to completion so the connection releases promptly,
// then drop it on the floor — we just want the server-side persist.
await r.blob();
const filename = parseFilenameFromContentDisposition(r.headers.get('Content-Disposition'))
|| `event ${idx}`;
setStatus(`Saved ${filename} to database + waveform store`, 'ok');
} catch (e) {
setStatus(`Save error: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
// ── Download the event file to the user's computer ──────────────────────────
//
// Uses a transient anchor + click trick so the browser surfaces its native
// "Save As" / Downloads behaviour. Same backend endpoint as Save to DB —
// the file is also persisted to the server store as a side effect.
function downloadEventFile() {
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
const idx = currentEvent;
const url = `${api()}/device/event/${idx}/blastware_file?${deviceParams()}`;
setStatus(`Downloading event #${idx}`, 'loading');
// Hidden iframe avoids navigating away from the SPA. FastAPI's FileResponse
// sets Content-Disposition: attachment so the browser saves rather than displays.
const a = document.createElement('a');
a.href = url;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// We can't reliably detect when the browser finishes downloading; show a
// soft confirmation immediately. Errors will surface as a download failure
// dialog from the browser itself.
setTimeout(() => setStatus(`Download started for event #${idx} (also saved server-side)`, 'ok'), 250);
}
function parseFilenameFromContentDisposition(header) {
if (!header) return null;
// RFC 6266: `attachment; filename="M529LKIQ.7M0W"` (or filename*=UTF-8''…)
const m = /filename\*?=(?:UTF-8'')?["']?([^"';]+)["']?/i.exec(header);
return m ? decodeURIComponent(m[1]) : null;
}
// renderWaveform consumes the `sfm.plot.v1` JSON shape:
// {
// schema: "sfm.plot.v1",
// time_axis: { sample_rate, pretrig_samples, t0_ms, dt_ms, n_samples, ... },
// channels: { Tran|Vert|Long|MicL: { unit, values, peak, peak_t_ms } },
// geo_range, geo_full_scale_ips, trigger_ms, peak_values, ...
// }
//
// All sample arrays are already in PHYSICAL UNITS (in/s for geo, psi for
// mic) — the server applied the right scaling for the unit's geo_range.
// The viewer used to multiply ADC ints by `geoAdcScale / 32767` here,
// which silently scaled every plot ~38% too low because `geoAdcScale` is
// the in/s-per-V hardware constant, not the ADC-counts-to-velocity
// factor. No scaling happens client-side now.
function renderWaveform(data) {
// Backward-compat shim: if we ever get the legacy shape from a stale
// cache, normalise it on the client so the viewer still works.
if (!data.schema && data.channels && Array.isArray(data.channels.Tran)) {
data = _legacyWaveformToPlotV1(data);
}
const t = data.time_axis || {};
const sr = t.sample_rate || 1024;
const pretrig = t.pretrig_samples || 0;
const total = t.total_samples || t.n_samples || 0;
const decoded = t.n_samples || 0;
const t0 = t.t0_ms ?? -(pretrig / sr * 1000);
const dt = t.dt_ms ?? (1000 / sr);
const channels = data.channels || {};
// Status bar
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
// Title prefers `index` (live device, 0-based slot on the unit) and
// falls back to event_id (DB lookup) when index is absent.
const eventLabel = (data.index != null) ? `#${data.index}` : (data.event_id || '');
bar.textContent = ts ? `Event ${eventLabel}${ts} ` : `Event ${eventLabel} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${t.rectime_seconds ?? '?'} s`);
if (data.geo_range) addPill(`geo: ${data.geo_range} (${data.geo_full_scale_ips} in/s FS)`);
// Any record_type starting with "Waveform" is a viewable triggered
// event (the timestamp-header byte layout varies across firmware but
// doesn't change the sample stream). Only block when there's actually
// no waveform payload to plot.
const isWaveformLike = !!(data.record_type || '').match(/^Waveform/i);
if (decoded === 0) {
document.getElementById('empty-state').style.display = 'flex';
document.getElementById('empty-state').querySelector('p').textContent =
isWaveformLike
? 'No samples decoded — check server logs'
: `Record type "${data.record_type}" — not a waveform event`;
document.getElementById('charts').style.display = 'none';
Object.values(charts).forEach(c => c.destroy()); charts = {};
return;
}
// Time axis: explicit ms values from t0_ms + i*dt_ms. More precise
// than the old (i - pretrig) / sr * 1000 since dt_ms came from the
// server with full float precision.
const times = Array.from({length: decoded}, (_, i) => (t0 + i * dt).toFixed(2));
document.getElementById('empty-state').style.display = 'none';
const chartsDiv = document.getElementById('charts');
chartsDiv.style.display = 'flex';
chartsDiv.innerHTML = '';
Object.values(charts).forEach(c => c.destroy()); charts = {};
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const chData = channels[ch];
if (!chData || !chData.values || chData.values.length === 0) continue;
const plotData = chData.values;
const unit = chData.unit || (ch === 'MicL' ? 'psi' : 'in/s');
const peak = chData.peak;
const peakTms = chData.peak_t_ms;
let peakLabel, ttFmt, tickFmt;
if (unit === 'psi') {
const peakDbl = (peak != null && peak > 0)
? 20 * Math.log10(peak / DBL_REF) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL (${peak != null ? peak.toExponential(2) : '—'} psi)`;
ttFmt = v => `${v.toExponential(3)} psi`;
tickFmt = v => v.toExponential(1);
} else {
peakLabel = peak != null ? `${peak.toFixed(5)} in/s` : '—';
ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4);
}
// Downsample for display when the chart would otherwise have to
// rasterise tens of thousands of points. Uses every-Nth — fine for
// monthly-summary glance work; analysis tools should use the .h5 file.
const MAX_PTS = 4000;
let rTimes = times, rData = plotData, peakPlotIdx = -1;
if (plotData.length > MAX_PTS) {
const step = Math.ceil(plotData.length / MAX_PTS);
rTimes = times.filter((_, i) => i % step === 0);
rData = plotData.filter((_, i) => i % step === 0);
// Try to keep the peak sample from being downsampled away.
if (peakTms != null) {
const exactIdx = Math.round((peakTms - t0) / dt);
if (exactIdx >= 0 && exactIdx < plotData.length) {
peakPlotIdx = Math.floor(exactIdx / step);
}
}
} else if (peakTms != null) {
peakPlotIdx = Math.round((peakTms - t0) / dt);
}
const wrap = document.createElement('div');
wrap.className = 'chart-wrap';
const lbl = document.createElement('div');
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
lbl.textContent = `${ch} — peak ${peakLabel}`;
wrap.appendChild(lbl);
const cw = document.createElement('div');
cw.className = 'chart-canvas-wrap';
const canvas = document.createElement('canvas');
cw.appendChild(canvas); wrap.appendChild(cw); chartsDiv.appendChild(wrap);
charts[ch] = new Chart(canvas, {
type: 'line',
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, 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 => `t = ${items[0].label} ms`, label: item => ttFmt(item.raw) },
},
},
scales: {
x: { type: 'category', ticks: { color:'#484f58', maxTicksLimit:10, maxRotation:0, callback:(v,i) => rTimes[i]+' ms' }, grid: { color:'#21262d' } },
y: { ticks: { color:'#484f58', maxTicksLimit:5, callback: v => tickFmt(v) }, grid: { color:'#21262d' }, title: { display:true, text:unit, color:'#484f58', font:{size:10} } },
},
},
plugins: [{
id: 'triggerAndPeakMarkers',
afterDraw(chart) {
const { ctx, scales: {x, y} } = chart;
// Trigger line at t = trigger_ms (typically 0).
const triggerMs = data.trigger_ms ?? 0;
const zeroIdx = rTimes.findIndex(s => parseFloat(s) >= triggerMs);
if (zeroIdx >= 0) {
const px = x.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
ctx.strokeStyle = 'rgba(248,81,73,0.7)'; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
}
// Peak marker (dot at the channel's peak sample).
if (peakPlotIdx >= 0 && peakPlotIdx < rData.length) {
const px = x.getPixelForValue(peakPlotIdx);
const py = y.getPixelForValue(rData[peakPlotIdx]);
ctx.save();
ctx.beginPath();
ctx.arc(px, py, 3.2, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.strokeStyle = '#0d1117';
ctx.lineWidth = 1.5;
ctx.fill(); ctx.stroke();
ctx.restore();
}
},
}],
});
}
}
// One-time normaliser for the legacy /device/event/{idx}/waveform shape
// (samples as int16 ADC counts in `channels.{ch}: [...]`). Bridges the
// gap if a stale cache or non-upgraded server returns the old format.
function _legacyWaveformToPlotV1(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;
const dt = 1000 / sr;
const t0 = -pretrig * dt;
// Apply the CORRECT scale: 10 in/s full-scale for Normal range.
const geoFs = 10.0;
const geoScale = geoFs / 32768;
const ch = data.channels || {};
const micPeak = data.peak_values?.micl_psi ?? null;
const micPeakCounts = (ch.MicL || ch.Mic || []).reduce((m, v) => Math.max(m, Math.abs(v)), 0);
const micScale = (micPeak != null && micPeakCounts > 0) ? micPeak / micPeakCounts : 1.0;
const mkGeo = (counts) => {
if (!counts || !counts.length) return [];
return counts.map(c => c * geoScale);
};
const mkMic = (counts) => {
if (!counts || !counts.length) return [];
return counts.map(c => c * micScale);
};
return {
schema: 'sfm.plot.v1',
event_id: data.event_id || null,
serial: data.serial || '',
timestamp: data.timestamp?.display || data.timestamp || '',
record_type: data.record_type,
waveform_key: null,
time_axis: {
sample_rate: sr, pretrig_samples: pretrig, total_samples: total,
n_samples: decoded, t0_ms: t0, dt_ms: dt,
rectime_seconds: data.rectime_seconds || 0,
},
geo_range: 'normal', geo_full_scale_ips: geoFs, trigger_ms: 0,
channels: {
Tran: { unit:'in/s', values: mkGeo(ch.Tran), peak: data.peak_values?.tran_in_s ?? null, peak_t_ms: null },
Vert: { unit:'in/s', values: mkGeo(ch.Vert), peak: data.peak_values?.vert_in_s ?? null, peak_t_ms: null },
Long: { unit:'in/s', values: mkGeo(ch.Long), peak: data.peak_values?.long_in_s ?? null, peak_t_ms: null },
MicL: { unit:'psi', values: mkMic(ch.MicL || ch.Mic), peak: micPeak, peak_t_ms: null },
},
peak_values: data.peak_values || {},
};
}
// ── DB tabs ────────────────────────────────────────────────────────────────────
let histLoaded = false;
let unitsLoaded = false;
let monlogLoaded = false;
let sessLoaded = false;
// Shared serial filter options — populated from /db/units
const _unitSerials = new Set();
function _ppvClass(v) {
const n = (v == null) ? null : Number(v);
if (n == null || !isFinite(n)) return '';
if (n >= 2.0) return 'ppv-high';
if (n >= 0.5) return 'ppv-warn';
return 'ppv-ok';
}
function _ppvFmt(v) {
if (v == null) return '—';
const n = typeof v === 'number' ? v : Number(v);
return isFinite(n) ? n.toFixed(5) : String(v);
}
function _fmtTs(ts) {
if (!ts) return '—';
// ts is ISO string; show date + time, strip trailing seconds if all zeros
const d = new Date(ts);
return d.toLocaleString();
}
function _fmtDur(sec) {
if (sec == null) return '—';
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function _populateSerialDropdown(selectId, currentVal) {
const sel = document.getElementById(selectId);
const prev = currentVal ?? sel.value;
sel.innerHTML = '<option value="">All units</option>';
for (const sn of [..._unitSerials].sort()) {
const opt = document.createElement('option');
opt.value = sn; opt.textContent = sn;
sel.appendChild(opt);
}
if (prev) sel.value = prev;
}
async function _fetchUnits() {
try {
const r = await fetch(`${api()}/db/units`);
if (!r.ok) return [];
return await r.json();
} catch { return []; }
}
// ── History tab ────────────────────────────────────────────────────────────────
// Module-level state for the history table — preserved across re-sorts.
// We sort + re-render without re-fetching.
let _histEvents = [];
let _histSortKey = 'timestamp';
let _histSortDir = 'desc'; // 'asc' | 'desc'
async function loadHistory() {
histLoaded = true;
const serial = document.getElementById('hist-serial-filter').value;
const from_dt = document.getElementById('hist-from').value;
const to_dt = document.getElementById('hist-to').value;
const hideFT = document.getElementById('hist-hide-ft').checked;
let url = `${api()}/db/events?limit=500`;
if (serial) url += `&serial=${encodeURIComponent(serial)}`;
if (from_dt) url += `&from_dt=${encodeURIComponent(from_dt)}`;
if (to_dt) url += `&to_dt=${encodeURIComponent(to_dt + 'T23:59:59')}`;
let data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
document.getElementById('hist-count').textContent = `Error: ${e.message}`;
return;
}
let events = data.events || [];
if (hideFT) events = events.filter(ev => !ev.false_trigger);
// Update serial dropdowns with any new serials seen
events.forEach(ev => { if (ev.serial) _unitSerials.add(ev.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
_histEvents = events;
renderHistTable();
}
// Re-render the history table from `_histEvents` using the current sort
// state. Pulled out of `loadHistory` so column-header clicks can re-sort
// in-memory without re-fetching from the server.
function renderHistTable() {
const events = _histEvents;
document.getElementById('hist-count').textContent =
`${events.length} event${events.length !== 1 ? 's' : ''}`;
const tbody = document.getElementById('hist-tbody');
tbody.innerHTML = '';
if (events.length === 0) {
document.getElementById('hist-empty').style.display = 'block';
document.getElementById('hist-table-wrap').style.display = 'none';
return;
}
document.getElementById('hist-empty').style.display = 'none';
document.getElementById('hist-table-wrap').style.display = 'block';
// Sort in-place by current key + direction. Nulls sink to the bottom
// regardless of direction.
const k = _histSortKey;
const dir = _histSortDir === 'asc' ? 1 : -1;
const sorted = [...events].sort((a, b) => {
const av = a[k], bv = b[k];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
// Update arrow indicators in the headers
document.querySelectorAll('#hist-header-row th[data-sort]').forEach(th => {
const arrow = th.querySelector('.sort-arrow');
if (!arrow) return;
arrow.textContent = th.dataset.sort === k ? (_histSortDir === 'asc' ? '↑' : '↓') : '';
});
for (const ev of sorted) {
const tr = document.createElement('tr');
const pvs = ev.peak_vector_sum;
tr.classList.add('clickable');
tr.title = 'Click to view waveform + sidecar';
tr.dataset.eventId = ev.id;
tr.innerHTML = `
<td>${_fmtTs(ev.timestamp)}</td>
<td class="td-key">${ev.serial ?? '—'}</td>
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
<td class="${_ppvClass(ev.vert_ppv)}">${_ppvFmt(ev.vert_ppv)}</td>
<td class="${_ppvClass(ev.long_ppv)}">${_ppvFmt(ev.long_ppv)}</td>
<td class="${_ppvClass(pvs)}">${_ppvFmt(pvs)}</td>
<td class="td-dim">${(() => {
const m = ev.mic_ppv == null ? null : Number(ev.mic_ppv);
if (m == null || !isFinite(m) || m <= 0) return '—';
// Series III (MiniMate Plus / BW) stores mic_ppv as psi → convert.
// Series IV (Micromate / Thor) already stores dB(L) → display direct.
if (ev.device_family === 'series4') return m.toFixed(1) + ' dBL';
return (20 * Math.log10(m / DBL_REF)).toFixed(1) + ' dBL';
})()}</td>
<td class="td-text">${ev.project ?? '—'}</td>
<td class="td-text">${ev.client ?? '—'}</td>
<td class="td-dim">${ev.record_type ?? '—'}</td>
<td class="td-dim" style="font-size:10px">${ev.waveform_key ?? '—'}</td>
<td>${ev.false_trigger ? '<span class="ft-badge">FALSE</span>' : ''}</td>
`;
tr.addEventListener('click', () => openSidecarModal(ev.id));
tbody.appendChild(tr);
}
}
// Click a column header → toggle sort. Click another → set sort to that column.
document.addEventListener('DOMContentLoaded', () => {
const headerRow = document.getElementById('hist-header-row');
if (!headerRow) return;
headerRow.querySelectorAll('th[data-sort]').forEach(th => {
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
th.addEventListener('click', () => {
const k = th.dataset.sort;
if (_histSortKey === k) {
_histSortDir = _histSortDir === 'asc' ? 'desc' : 'asc';
} else {
_histSortKey = k;
// Default direction: 'desc' for numbers + timestamps (biggest/newest first),
// 'asc' for text columns (alphabetical).
_histSortDir = ['serial','project','client','record_type','waveform_key'].includes(k) ? 'asc' : 'desc';
}
renderHistTable();
});
});
});
// ── Sidecar review modal ───────────────────────────────────────────────────────
//
// Opens on row click in the History table. Loads the .sfm.json sidecar
// for the event via GET /db/events/{id}/sidecar, lets the user toggle
// false_trigger / edit notes / set reviewer, and saves via PATCH on the
// same URL. This mirrors the workflow used by the monthly vibration
// summary process — most of the rich review UX lives in Terra-View;
// this is the SFM-standalone equivalent for testing / direct edits.
let _scCurrentEventId = null;
let _scCurrentSidecar = null;
async function openSidecarModal(eventId) {
_scCurrentEventId = eventId;
_scCurrentSidecar = null;
document.getElementById('sc-status').textContent = 'Loading sidecar…';
document.getElementById('sc-status').className = 'sc-status';
document.getElementById('sc-overlay').classList.add('visible');
// Reset edit fields
document.getElementById('sc-edit-ft').checked = false;
document.getElementById('sc-edit-reviewer').value = '';
document.getElementById('sc-edit-notes').value = '';
// Reset waveform area
document.getElementById('sc-waveform-status').textContent = 'Loading waveform…';
document.getElementById('sc-waveform-charts').innerHTML = '';
_destroyScCharts();
// Sidecar + waveform fetched in parallel — neither blocks the other.
const sidecarP = fetch(`${api()}/db/events/${eventId}/sidecar`)
.then(async r => {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
return r.json();
});
const waveformP = fetch(`${api()}/db/events/${eventId}/waveform.json`)
.then(async r => {
if (r.status === 404) return null; // no waveform available — render empty state
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
return r.json();
});
// Sidecar usually loads first (smaller payload). Each one renders
// independently so the modal becomes useful as soon as either lands.
sidecarP.then(data => {
_scCurrentSidecar = data;
_renderSidecar(data);
document.getElementById('sc-status').textContent = '';
}).catch(e => {
document.getElementById('sc-status').className = 'sc-status error';
document.getElementById('sc-status').textContent = `Sidecar load failed: ${e.message}`;
});
waveformP.then(data => {
if (!data) {
document.getElementById('sc-waveform-status').textContent = 'No waveform data for this event.';
return;
}
_renderScWaveform(data);
}).catch(e => {
document.getElementById('sc-waveform-status').textContent = `Waveform load failed: ${e.message}`;
});
}
// ── Sidecar-modal waveform plot ──────────────────────────────────────────────
// Renders the 4-channel decoded waveform fetched from
// /db/events/{id}/waveform.json — MicL on top, Tran on bottom (matches
// Instantel BW Event Report layout). Uses Chart.js (loaded at the top of
// the page for the live-device viewer).
const _SC_CHANNEL_COLORS = {
MicL: '#e066ff',
Long: '#3a80ff',
Vert: '#3fb950',
Tran: '#f85149',
};
const _SC_CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
let _scCharts = {};
// User preference for how mic is displayed in plots — dBL (default,
// matches BW printout convention + the rest of SFM) or psi (the raw
// sample unit). Toggleable via the header pill; persists in localStorage.
function _getMicUnit() {
return localStorage.getItem('sfm_mic_unit') === 'psi' ? 'psi' : 'dBL';
}
function _setMicUnit(u) {
localStorage.setItem('sfm_mic_unit', u === 'psi' ? 'psi' : 'dBL');
_refreshMicUnitToggleLabel();
// Re-render the open modal so the change is immediately visible.
if (_scCurrentEventId) openSidecarModal(_scCurrentEventId);
}
function _refreshMicUnitToggleLabel() {
const b = document.getElementById('mic-unit-toggle');
if (b) b.textContent = `Mic: ${_getMicUnit()}`;
}
// Convert a psi value to dB(L). Returns null for non-positive values
// (log of zero is undefined) — Chart.js handles null as a gap in the line.
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Adaptive decimal formatter — scientific notation is reserved for truly
// extreme values (10000+ or sub-0.0001). Normal-range values (most peaks
// fall here) render as decimals with sensible precision. Replaces the
// previous .toExponential(3) call that turned every peak into ugly "2.500E-2".
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 _destroyScCharts() {
Object.values(_scCharts).forEach(c => { try { c.destroy(); } catch {} });
_scCharts = {};
}
function _renderScWaveform(data) {
document.getElementById('sc-waveform-status').textContent = '';
const chartsDiv = document.getElementById('sc-waveform-charts');
chartsDiv.innerHTML = '';
_destroyScCharts();
const channels = data.channels || {};
// time_axis is METADATA, not an array — it carries sample_rate,
// pretrig_samples, t0_ms (first-sample time relative to trigger,
// negative when pretrig samples exist), and dt_ms. Trigger is at
// t=0 by convention.
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;
// Histogram events have per-interval peaks, not per-sample data.
// Render as bars (one per interval) instead of a connected line, and
// suppress trigger/zero overlays which don't apply. X-axis becomes
// interval index since the sample_rate-based time math is meaningless
// here (each "sample" is one interval, typically 1-5 minutes long).
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
// Which channels have data — determines which one renders the shared bottom axis.
const withData = _SC_CHANNEL_ORDER.filter(ch =>
channels[ch] && (channels[ch].values || []).length > 0
);
const lastCh = withData[withData.length - 1];
const micUnit = _getMicUnit(); // user preference: 'dBL' or 'psi'
for (const ch of _SC_CHANNEL_ORDER) {
const chData = channels[ch];
if (!chData) continue;
let values = chData.values || [];
let chUnit = chData.unit || '';
let chPeak = chData.peak;
// Mic channel: convert from raw psi to dB(L) when user prefers dBL
// (default). Mic samples that are zero/negative become null (Chart.js
// renders them as gaps in line mode, zero-height bars in histogram mode).
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDbl);
chPeak = _psiToDbl(chPeak);
chUnit = 'dB(L)';
}
const wrap = document.createElement('div');
wrap.style.cssText = 'background:var(--surface);border:1px solid var(--border2);border-radius:6px;padding:6px 30px 4px 10px';
const lbl = document.createElement('div');
lbl.style.cssText = `font-size:10px;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:2px;color:${_SC_CHANNEL_COLORS[ch]};display:flex;justify-content:space-between`;
const peakStr = chPeak != null
? `peak ${_fmtPeak(chPeak, chUnit)}`
: '';
lbl.innerHTML = `<span>${ch}</span><span style="color:var(--text-dim);font-weight:normal">${peakStr}</span>`;
wrap.appendChild(lbl);
if (values.length === 0) {
const e = document.createElement('div');
e.style.cssText = 'height:80px;display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:11px';
e.textContent = 'no samples decoded';
wrap.appendChild(e);
chartsDiv.appendChild(wrap);
continue;
}
const canvasWrap = document.createElement('div');
canvasWrap.style.cssText = 'position:relative;height:100px';
const canvas = document.createElement('canvas');
canvasWrap.appendChild(canvas);
wrap.appendChild(canvasWrap);
chartsDiv.appendChild(wrap);
// Waveform: per-sample time in ms relative to trigger (negative for pretrig).
// Histogram: when the server has aggregated to BW-reported intervals AND
// provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
// Falls back to 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);
// Tick label formatter: snap floats to 1 decimal place so we don't get
// "11.7187040000000002 ms" garbage from accumulated floating-point error.
const xAxisLabel = isHistogram ? '' : ' ms';
const fmtTick = i => {
const v = rT[i];
if (typeof v === 'number') {
// Whole numbers (intervals) → no decimals. Sub-integer ms → 1 decimal.
const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
return s + xAxisLabel;
}
return String(v) + xAxisLabel;
};
// Y-axis bounds. Convention:
// - Geophones (Tran/Vert/Long) on waveform-mode events:
// symmetric around zero so the zero line sits in the middle and
// positive/negative excursions are visually balanced.
// - Mic (always positive sound pressure) + histograms (per-interval
// peaks, always positive): default auto-scale, zero at the bottom.
let yBounds = {};
const isGeoWaveform = !isHistogram && ch !== 'MicL';
if (isGeoWaveform) {
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 };
}
_scCharts[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: _SC_CHANNEL_COLORS[ch],
borderWidth: 0,
barPercentage: 1.0,
categoryPercentage: 1.0, // bars touch — "tight bargraph" look
}] : [{
data: rV,
borderColor: _SC_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: '#484f58', maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
grid: { color: '#21262d', drawTicks: showX },
},
y: {
...yBounds,
ticks: { color: '#484f58', maxTicksLimit: 4 },
grid: { color: '#21262d' },
title: { display: true, text: chUnit, color: '#484f58', font: { size: 9 } },
},
},
},
plugins: isHistogram ? [] : [{
// Trigger line + triangle markers + zero baseline — only meaningful
// for waveform-mode events. Histograms have no trigger.
id: 'overlays',
afterDraw(chart) {
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
// Dashed trigger line at t=0
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(248,81,73,0.8)'; ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
// Triangle markers above and below the chart
ctx.save();
ctx.fillStyle = '#f85149';
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();
}
// Zero baseline + label
const zy = y.getPixelForValue(0);
if (zy >= y.top && zy <= y.bottom) {
ctx.save();
ctx.strokeStyle = '#30363d'; 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 = '#c9d1d9'; ctx.font = '10px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('0.0', x.right + 6, zy);
ctx.restore();
}
},
}],
});
}
}
// Make sure charts get cleaned up when the modal closes.
function _scCleanupOnClose() { _destroyScCharts(); }
function _renderSidecar(data) {
const ev = data.event || {};
const pv = data.peak_values || {};
const pi = data.project_info || {};
const bw = data.blastware || {};
const src = data.source || {};
const rev = data.review || {};
document.getElementById('sc-title').textContent = `Event — ${bw.filename || ev.waveform_key || 'unknown'}`;
const fmtPpv = v => {
if (v == null) return '—';
const n = Number(v);
return isFinite(n) ? n.toFixed(5) + ' in/s' : String(v);
};
// Map sidecar source.kind → device family (Series IV ingest path is
// "idf-import"; everything else is Series III today). The events-list
// table uses ev.device_family from the DB row, but sidecars don't carry
// that column — source.kind is the equivalent signal here.
const family = ((src.kind || '') === 'idf-import') ? 'series4' : 'series3';
const fmtMic = v => {
if (v == null) return '—';
const n = Number(v);
if (!isFinite(n) || n <= 0) return '—';
// Series IV (Micromate / Thor) stores mic as dB(L); Series III (BW)
// stores it as psi and we render both for cross-reference.
if (family === 'series4') return `${n.toFixed(1)} dBL`;
const dbl = 20 * Math.log10(n / DBL_REF);
return `${dbl.toFixed(1)} dBL (${n.toExponential(2)} psi)`;
};
document.getElementById('sc-f-serial').textContent = ev.serial || '—';
// Route through _fmtTs so the unit-local naive timestamp shows as
// "5/27/2026, 6:00:13 AM" instead of "2026-05-27T06:00:13".
document.getElementById('sc-f-ts').textContent = _fmtTs(ev.timestamp);
document.getElementById('sc-f-rt').textContent = ev.record_type || '—';
document.getElementById('sc-f-sr').textContent = (ev.sample_rate ?? '—') + (ev.sample_rate ? ' sps' : '');
document.getElementById('sc-f-key').textContent = ev.waveform_key || '—';
document.getElementById('sc-f-tran').textContent = fmtPpv(pv.transverse);
document.getElementById('sc-f-vert').textContent = fmtPpv(pv.vertical);
document.getElementById('sc-f-long').textContent = fmtPpv(pv.longitudinal);
document.getElementById('sc-f-pvs').textContent = fmtPpv(pv.vector_sum);
document.getElementById('sc-f-mic').textContent = fmtMic(pv.mic_psi);
document.getElementById('sc-f-project').textContent = pi.project || '—';
document.getElementById('sc-f-client').textContent = pi.client || '—';
document.getElementById('sc-f-operator').textContent = pi.operator || '—';
document.getElementById('sc-f-loc').textContent = pi.sensor_location || '—';
document.getElementById('sc-f-bw').textContent = bw.filename || '—';
document.getElementById('sc-f-bwsize').textContent = bw.filesize != null ? `${bw.filesize} bytes` : '—';
document.getElementById('sc-f-sha').textContent = bw.sha256 || '—';
document.getElementById('sc-f-src').textContent = src.kind || '—';
// captured_at has a "Z" suffix (UTC); _fmtTs converts to browser local
// — matches the BW-reported recorded-at, no more "21:59:57 vs it's 6 PM"
// confusion from operators reading the raw UTC value.
document.getElementById('sc-f-cap').textContent = _fmtTs(src.captured_at);
document.getElementById('sc-edit-ft').checked = !!rev.false_trigger;
document.getElementById('sc-edit-reviewer').value = rev.reviewer || '';
document.getElementById('sc-edit-notes').value = rev.notes || '';
document.getElementById('sc-raw-json').textContent = JSON.stringify(data, null, 2);
}
function closeSidecarModal() {
document.getElementById('sc-overlay').classList.remove('visible');
_scCurrentEventId = null;
_scCurrentSidecar = null;
_destroyScCharts();
}
// Trigger a PDF download for the currently-open event. The browser
// handles the actual save dialog from the Content-Disposition header
// the server sends.
function downloadEventReport() {
if (!_scCurrentEventId) return;
const url = `${api()}/db/events/${_scCurrentEventId}/report.pdf`;
// Open in a new tab — browser prompts to save or displays inline,
// and a failed fetch (e.g. 404 for events with no waveform) shows
// its JSON error in-page rather than silently failing.
window.open(url, '_blank');
}
function onSidecarOverlayClick(e) {
// Click on the dimmed backdrop (but NOT on the modal itself) closes.
if (e.target.id === 'sc-overlay') closeSidecarModal();
}
async function saveSidecarReview() {
if (!_scCurrentEventId) return;
const btn = document.getElementById('sc-save-btn');
const status = document.getElementById('sc-status');
btn.disabled = true;
status.className = 'sc-status';
status.textContent = 'Saving…';
const review = {
false_trigger: document.getElementById('sc-edit-ft').checked,
reviewer: document.getElementById('sc-edit-reviewer').value.trim() || null,
notes: document.getElementById('sc-edit-notes').value,
};
try {
const r = await fetch(`${api()}/db/events/${_scCurrentEventId}/sidecar`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review }),
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText);
}
const updated = await r.json();
_scCurrentSidecar = updated;
_renderSidecar(updated);
status.className = 'sc-status ok';
status.textContent = 'Saved.';
// Refresh the History table so the false_trigger badge reflects the change.
if (typeof loadHistory === 'function') loadHistory();
setTimeout(closeSidecarModal, 600);
} catch (e) {
status.className = 'sc-status error';
status.textContent = `Save failed: ${e.message}`;
} finally {
btn.disabled = false;
}
}
// Esc closes the modal.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.getElementById('sc-overlay').classList.contains('visible')) {
closeSidecarModal();
}
});
// ── Units tab ──────────────────────────────────────────────────────────────────
async function loadUnits() {
unitsLoaded = true;
const units = await _fetchUnits();
units.forEach(u => { if (u.serial) _unitSerials.add(u.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
document.getElementById('units-count').textContent = `${units.length} unit${units.length !== 1 ? 's' : ''}`;
const grid = document.getElementById('units-grid');
grid.innerHTML = '';
if (units.length === 0) {
document.getElementById('units-empty').style.display = 'block';
return;
}
document.getElementById('units-empty').style.display = 'none';
for (const u of units) {
const card = document.createElement('div');
card.className = 'unit-card';
card.title = 'Click to filter History by this unit';
card.onclick = () => {
_populateSerialDropdown('hist-serial-filter', u.serial);
switchTab('history');
loadHistory();
};
const lastSeen = u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—';
card.innerHTML = `
<div class="uc-serial">${u.serial}</div>
<div class="uc-stat"><span class="uc-label">Events</span><span class="uc-val">${u.total_events ?? 0}</span></div>
<div class="uc-stat"><span class="uc-label">Monitor entries</span><span class="uc-val">${u.total_monitor_entries ?? 0}</span></div>
<div class="uc-stat"><span class="uc-label">Sessions</span><span class="uc-val">${u.total_sessions ?? 0}</span></div>
<div class="uc-stat"><span class="uc-label">Last seen</span><span class="uc-val">${lastSeen}</span></div>
`;
grid.appendChild(card);
}
}
// ── Monitor Log tab ────────────────────────────────────────────────────────────
async function loadMonitorLog() {
monlogLoaded = true;
const serial = document.getElementById('monlog-serial-filter').value;
const from_dt = document.getElementById('monlog-from').value;
const to_dt = document.getElementById('monlog-to').value;
let url = `${api()}/db/monitor_log?`;
if (serial) url += `serial=${encodeURIComponent(serial)}&`;
if (from_dt) url += `from_dt=${encodeURIComponent(from_dt)}&`;
if (to_dt) url += `to_dt=${encodeURIComponent(to_dt + 'T23:59:59')}&`;
let data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
document.getElementById('monlog-count').textContent = `Error: ${e.message}`;
return;
}
const entries = data.entries || [];
entries.forEach(e => { if (e.serial) _unitSerials.add(e.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
document.getElementById('monlog-count').textContent = `${entries.length} entr${entries.length !== 1 ? 'ies' : 'y'}`;
const tbody = document.getElementById('monlog-tbody');
tbody.innerHTML = '';
if (entries.length === 0) {
document.getElementById('monlog-empty').style.display = 'block';
document.getElementById('monlog-table-wrap').style.display = 'none';
return;
}
document.getElementById('monlog-empty').style.display = 'none';
document.getElementById('monlog-table-wrap').style.display = 'block';
for (const e of entries) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${_fmtTs(e.start_time)}</td>
<td>${_fmtTs(e.stop_time)}</td>
<td>${_fmtDur(e.duration_seconds)}</td>
<td class="td-key">${e.serial ?? '—'}</td>
<td>${e.geo_threshold_ips != null ? e.geo_threshold_ips.toFixed(4) + ' in/s' : '—'}</td>
<td class="td-dim" style="font-size:10px">${e.key ?? '—'}</td>
`;
tbody.appendChild(tr);
}
}
// ── Sessions tab ───────────────────────────────────────────────────────────────
async function loadSessions() {
sessLoaded = true;
const serial = document.getElementById('sess-serial-filter').value;
let url = `${api()}/db/sessions?limit=200`;
if (serial) url += `&serial=${encodeURIComponent(serial)}`;
let data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
document.getElementById('sess-count').textContent = `Error: ${e.message}`;
return;
}
const sessions = data.sessions || [];
sessions.forEach(s => { if (s.serial) _unitSerials.add(s.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
document.getElementById('sess-count').textContent = `${sessions.length} session${sessions.length !== 1 ? 's' : ''}`;
const tbody = document.getElementById('sess-tbody');
tbody.innerHTML = '';
if (sessions.length === 0) {
document.getElementById('sess-empty').style.display = 'block';
document.getElementById('sess-table-wrap').style.display = 'none';
return;
}
document.getElementById('sess-empty').style.display = 'none';
document.getElementById('sess-table-wrap').style.display = 'block';
for (const s of sessions) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${_fmtTs(s.session_time)}</td>
<td class="td-key">${s.serial ?? '—'}</td>
<td class="td-dim">${s.peer ?? '—'}</td>
<td>${s.events_downloaded ?? 0}</td>
<td>${s.monitor_entries ?? 0}</td>
<td>${s.duration_seconds != null ? s.duration_seconds.toFixed(1) : '—'}</td>
`;
tbody.appendChild(tr);
}
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
});
// Default API base to wherever this page was served from — works whether you
// hit localhost:8200, 10.0.0.44:8200, or anything else.
document.getElementById('api-base').value = window.location.origin;
// Reflect any persisted mic-unit preference in the header pill on load
_refreshMicUnitToggleLabel();
// We default to Database view → trigger initial history + units load
// (switchSection handles this when clicked, but we never click on first paint).
if (currentSection === 'db') {
if (!histLoaded) loadHistory();
if (!unitsLoaded) loadUnits();
}
// Press Enter in any live connect field to connect
['dev-host','dev-port'].forEach(id => {
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
});
</script>
<!-- ════════════════════════════════════════════════════════════════
Sidecar review modal (Database events table → row click)
═══════════════════════════════════════════════════════════════════ -->
<div class="sc-overlay" id="sc-overlay" onclick="onSidecarOverlayClick(event)">
<div class="sc-modal" id="sc-modal">
<div class="sc-header">
<h3 id="sc-title">Event</h3>
<button class="sc-close" onclick="closeSidecarModal()">×</button>
</div>
<div class="sc-body">
<!-- Waveform plot — 4 channels stacked (MicL, Long, Vert, Tran) — -->
<div class="sc-section" id="sc-section-waveform">
<h4>Waveform</h4>
<div id="sc-waveform-status" style="color:var(--text-dim);font-size:11px;margin-bottom:6px">Loading…</div>
<div id="sc-waveform-charts" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<div class="sc-section">
<h4>Event</h4>
<dl class="sc-grid">
<dt>Serial</dt> <dd id="sc-f-serial"></dd>
<dt title="When the seismograph recorded this event (from the BW report's Event Time field)">Recorded at</dt>
<dd id="sc-f-ts"></dd>
<dt>Record type</dt> <dd id="sc-f-rt"></dd>
<dt>Sample rate</dt> <dd id="sc-f-sr"></dd>
<dt>Waveform key</dt> <dd id="sc-f-key"></dd>
</dl>
</div>
<div class="sc-section">
<h4>Peaks</h4>
<dl class="sc-grid">
<dt>Tran</dt> <dd id="sc-f-tran"></dd>
<dt>Vert</dt> <dd id="sc-f-vert"></dd>
<dt>Long</dt> <dd id="sc-f-long"></dd>
<dt>PVS</dt> <dd id="sc-f-pvs"></dd>
<dt>Mic</dt> <dd id="sc-f-mic"></dd>
</dl>
</div>
<div class="sc-section">
<h4>Project</h4>
<dl class="sc-grid">
<dt>Project</dt> <dd id="sc-f-project"></dd>
<dt>Client</dt> <dd id="sc-f-client"></dd>
<dt>Operator</dt> <dd id="sc-f-operator"></dd>
<dt>Location</dt> <dd id="sc-f-loc"></dd>
</dl>
</div>
<div class="sc-section">
<h4>Source / files</h4>
<dl class="sc-grid">
<dt id="sc-l-bw">Event file</dt> <dd id="sc-f-bw"></dd>
<dt id="sc-l-bwsize">File size</dt> <dd id="sc-f-bwsize"></dd>
<dt id="sc-l-sha">File sha256</dt> <dd id="sc-f-sha"></dd>
<dt>Source kind</dt> <dd id="sc-f-src"></dd>
<dt title="When our server received and stored this event (sfm-db insert time, not the recording time)">Received by server at</dt>
<dd id="sc-f-cap"></dd>
</dl>
</div>
<div class="sc-section">
<h4>Review (editable)</h4>
<div class="sc-row">
<input type="checkbox" id="sc-edit-ft" />
<label for="sc-edit-ft">False trigger</label>
</div>
<div class="sc-row">
<label for="sc-edit-reviewer" style="min-width:60px">Reviewer</label>
<input type="text" id="sc-edit-reviewer" placeholder="e.g. brian" />
</div>
<label for="sc-edit-notes" style="font-size:11px;color:var(--text-mute)">Notes</label>
<textarea id="sc-edit-notes" placeholder="e.g. truck thump near sensor 14:23 — false trigger"></textarea>
</div>
<details class="sc-raw">
<summary>Raw sidecar JSON (read-only peek)</summary>
<pre id="sc-raw-json"></pre>
</details>
</div>
<div class="sc-footer">
<span class="sc-status" id="sc-status"></span>
<button class="btn btn-ghost" id="sc-pdf-btn" onclick="downloadEventReport()"
title="Download an Instantel-style Event Report PDF for this event">
Download PDF
</button>
<button class="btn btn-ghost" onclick="closeSidecarModal()">Cancel</button>
<button class="btn" id="sc-save-btn" onclick="saveSidecarReview()">Save</button>
</div>
</div>
</div>
</body>
</html>