Files
seismo-relay/sfm/sfm_webapp.html
T
2026-04-14 23:59:17 -04:00

2157 lines
85 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;
}
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); }
.wf-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--accent);
cursor: pointer;
font-size: 13px;
padding: 1px 6px;
line-height: 1;
}
.wf-btn:hover { background: rgba(56,139,253,0.15); border-color: var(--accent); }
.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; }
/* ── Section containers ── */
#section-live, #section-db {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
#section-db { 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 active" onclick="switchSection('live')">Live Device</button>
<button class="section-btn" onclick="switchSection('db')">Database</button>
</div>
</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>
</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="prev-btn" onclick="stepEvent(-1)" disabled></button>
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--fg-muted);cursor:pointer;margin-left:4px"
title="Bypass server cache and re-download from device. Checking this auto-reloads if a waveform is already displayed.">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastWaveformData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<div class="event-chips" id="event-chips"></div>
</div>
<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>
<!-- Debug panel: raw ADC sample readout for diagnosing decode issues -->
<div id="debug-panel" style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('debug-panel').style.display='none'">hide</span>
<div id="debug-content"></div>
</div>
<div id="waveform-area" style="flex:1; overflow-y:auto;">
<div id="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>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>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>Max Range — Geo (in/s)</label>
<input type="number" id="cfg-max-range" step="0.001" min="0.001" placeholder="e.g. 6.206" />
<div class="hint">Full-scale calibration constant — only change if you have a cal cert</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 -->
</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>
<th></th>
<th>Timestamp</th>
<th>Serial</th>
<th>Tran (in/s)</th>
<th>Vert (in/s)</th>
<th>Long (in/s)</th>
<th>PVS (in/s)</th>
<th>Mic (dBL)</th>
<th>Project</th>
<th>Client</th>
<th>Type</th>
<th>Key</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 geoRange = 6.206;
let lastWaveformData = null; // last successfully rendered waveform payload
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#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;
}
function deviceParams() {
return `host=${encodeURIComponent(devHost())}&tcp_port=${devPort()}`;
}
// ── Section switching ─────────────────────────────────────────────────────────
let currentSection = 'live';
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('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;
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 || '—';
geoRange = cc.max_range_geo ?? 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 complianceRows = [
['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'],
['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.max_range_geo != null ? `${cc.max_range_geo.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.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
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.max_range_geo != null) qs('cfg-max-range',cc.max_range_geo.toFixed(4));
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-max-range',
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes']
.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 sr = qs('cfg-sample-rate').value;
if (sr) body.sample_rate = parseInt(sr, 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 mr = qs('cfg-max-range').value;
if (mr) body.max_range_geo = parseFloat(mr);
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;
}
}
// ── 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;
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
document.getElementById('load-btn').disabled = true;
setStatus('Fetching waveform…', 'loading');
let data;
try {
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}${force}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch(e) {
setStatus(`Waveform error: ${e.message}`, 'error');
document.getElementById('load-btn').disabled = false;
return;
}
lastWaveformData = data;
renderWaveform(data);
document.getElementById('load-btn').disabled = false;
}
// ── Shared waveform chart builder ──────────────────────────────────────────────
// Renders waveform channel charts into chartsEl, destroys+replaces instances in
// chartsStore. emptyEl (optional) is shown/hidden based on decoded sample count.
function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const channels = data.channels || {};
// Destroy old chart instances
Object.values(chartsStore).forEach(c => c.destroy());
for (const k in chartsStore) delete chartsStore[k];
if (decoded === 0) {
if (emptyEl) {
emptyEl.style.display = 'flex';
const p = emptyEl.querySelector('p');
if (p) p.textContent = data.record_type === 'Waveform'
? 'No samples decoded — check server logs'
: `Record type "${data.record_type}" — waveform not supported yet`;
}
chartsEl.style.display = 'none';
chartsEl.innerHTML = '';
return;
}
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
if (emptyEl) emptyEl.style.display = 'none';
chartsEl.style.display = 'flex';
chartsEl.style.flexDirection = 'column';
chartsEl.style.gap = '8px';
chartsEl.innerHTML = '';
const micPeakPsi = data.peak_values?.micl_psi ?? null;
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch];
if (!samples || samples.length === 0) continue;
const isGeo = ch !== 'Mic';
let plotData, peakLabel, yUnit, ttFmt, tickFmt;
if (isGeo) {
const scale = geoRange / 32767;
plotData = samples.map(s => s * scale);
// Use the device-recorded peak from the 0C waveform record — authoritative
// and matches Blastware. Computing from raw samples can catch rogue
// near-full-scale values from decoding artifacts.
const peakKey = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch];
const devicePeak = data.peak_values?.[peakKey] ?? null;
peakLabel = devicePeak != null ? `${devicePeak.toFixed(5)} in/s` : `${Math.max(...plotData.map(Math.abs)).toFixed(5)} in/s`;
yUnit = 'in/s';
ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4);
} else {
const peakCounts = Math.max(...samples.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0) ? Math.abs(micPeakPsi) / peakCounts : 1.0;
plotData = samples.map(s => s * micScale);
const peakPsi = Math.max(...plotData.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL`;
yUnit = 'psi';
ttFmt = v => `${v.toExponential(3)} psi`;
tickFmt = v => v.toExponential(1);
}
const MAX_PTS = 4000;
let rTimes = times, rData = plotData;
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);
}
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); chartsEl.appendChild(wrap);
chartsStore[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:yUnit, color:'#484f58', font:{size:10} } },
},
},
plugins: [{
id: 'triggerLine',
afterDraw(chart) {
const zeroIdx = rTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const { ctx, scales: {x, y} } = chart;
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();
},
}],
});
}
}
function renderWaveform(data) {
const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
lastWaveformData = data;
// Status bar
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
bar.textContent = ts ? `Event #${data.index}${ts.display} ` : `Event #${data.index} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${data.rectime_seconds ?? '?'} s`);
_buildWaveformCharts(data, document.getElementById('charts'), document.getElementById('empty-state'), charts);
updateDebugPanel(data);
}
// ── Debug panel population ─────────────────────────────────────────────────────
function _fillDebugPanel(data, dbg, cont) {
if (!dbg || !cont) return;
const channels = data.channels || {};
const pv = data.peak_values || {};
const scale = geoRange / 32767;
const geoChans = ['Tran', 'Vert', 'Long'];
let html = '<div style="display:flex;gap:24px;flex-wrap:wrap;">';
for (const ch of [...geoChans, 'Mic']) {
const raw = (channels[ch] || []).slice(0, 8);
if (raw.length === 0) continue;
const maxAbs = Math.max(...raw.map(Math.abs));
const keyMap = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' };
const p0c = ch !== 'Mic' ? (pv[keyMap[ch]] ?? null) : null;
const src = p0c !== null ? `<span style="color:#3fb950">0C=${p0c.toFixed(4)}</span>`
: `<span style="color:#e3b341">Math.max≈${(maxAbs*scale).toFixed(4)}</span>`;
html += `<div><span style="color:#8b949e">${ch} raw[0:8]:</span> <span style="color:#c9d1d9">${raw.join(', ')}</span> peak: ${src}</div>`;
}
html += '</div>';
const nullPeaks = geoChans.filter(ch => (pv[{ Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch]] ?? null) === null);
if (nullPeaks.length > 0) {
html += `<div style="color:#e3b341;margin-top:2px">⚠ peak0C null for: ${nullPeaks.join(', ')} — peaks shown are Math.max of waveform samples, not 0C record</div>`;
}
html += `<div style="color:#484f58;margin-top:2px">decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange.toFixed(3)}</div>`;
cont.innerHTML = html;
dbg.style.display = 'block';
}
function updateDebugPanel(data) {
_fillDebugPanel(data, document.getElementById('debug-panel'), document.getElementById('debug-content'));
}
// ── DB tabs ────────────────────────────────────────────────────────────────────
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) {
if (v == null) return '';
if (v >= 2.0) return 'ppv-high';
if (v >= 0.5) return 'ppv-warn';
return 'ppv-ok';
}
function _ppvFmt(v) {
return v != null ? v.toFixed(5) : '—';
}
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 ────────────────────────────────────────────────────────────────
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');
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';
for (const ev of events) {
const tr = document.createElement('tr');
const pvs = ev.peak_vector_sum;
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
tr.innerHTML = `
<td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform">〜</button></td>
<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">${ev.mic_ppv != null && ev.mic_ppv > 0 ? (20 * Math.log10(ev.mic_ppv / 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>' : `<button class="ft-toggle-btn" onclick="toggleFalseTrigger(${ev.id}, this)" title="Flag as false trigger">Flag</button>`}</td>
`;
tbody.appendChild(tr);
}
}
async function toggleFalseTrigger(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`${api()}/db/events/${id}/false_trigger?value=true`, { method: 'PATCH' });
if (!r.ok) throw new Error(r.statusText);
btn.outerHTML = '<span class="ft-badge">FALSE</span>';
} catch (e) {
btn.disabled = false;
alert(`Failed to flag: ${e.message}`);
}
}
// ── 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);
}
}
// ── DB waveform modal ─────────────────────────────────────────────────────────
let modalCharts = {};
async function openDbWaveformModal(id) {
const modal = document.getElementById('wf-modal');
const titleEl = document.getElementById('wf-modal-title');
const chartsEl = document.getElementById('wf-modal-charts');
const emptyEl = document.getElementById('wf-modal-empty');
const peaksEl = document.getElementById('wf-modal-peaks');
const debugEl = document.getElementById('wf-modal-debug');
// Show modal in loading state
titleEl.textContent = 'Loading…';
peaksEl.classList.remove('visible');
if (debugEl) debugEl.style.display = 'none';
chartsEl.style.display = 'none';
chartsEl.innerHTML = '';
emptyEl.style.display = 'flex';
emptyEl.querySelector('p').textContent = 'Loading waveform…';
modal.style.display = 'flex';
let data;
try {
const r = await fetch(`${api()}/db/events/${encodeURIComponent(id)}/waveform`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch(e) {
emptyEl.querySelector('p').textContent = `Error: ${e.message}`;
return;
}
// Normalize old blob peak_values keys (pre-fix ACH blobs used tran/vert/long without _in_s)
if (data.peak_values) {
const pv = data.peak_values;
if (pv.tran_in_s == null && pv.tran != null) pv.tran_in_s = pv.tran;
if (pv.vert_in_s == null && pv.vert != null) pv.vert_in_s = pv.vert;
if (pv.long_in_s == null && pv.long != null) pv.long_in_s = pv.long;
}
// Header — DB blobs have timestamp as ISO string; live device returns {display:...}
const sr = data.sample_rate || 1024;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const pretrig = data.pretrig_samples || 0;
let tsStr = '';
if (data.timestamp) {
const tsDisplay = typeof data.timestamp === 'object'
? (data.timestamp.display || String(data.timestamp))
: new Date(data.timestamp).toLocaleString();
tsStr = `<strong style="color:var(--text)">${tsDisplay}</strong> `;
}
titleEl.innerHTML = `${tsStr}<span style="color:var(--text-dim)">${data.record_type || '?'} · ${sr} sps · ${decoded.toLocaleString()} / ${total.toLocaleString()} samples · pretrig ${pretrig} · ${data.rectime_seconds ?? '?'} s</span>`;
// Peaks bar
const pv = data.peak_values || {};
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
document.getElementById('wf-mpk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
document.getElementById('wf-mpk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
peaksEl.classList.add('visible');
_buildWaveformCharts(data, chartsEl, emptyEl, modalCharts);
_fillDebugPanel(data, debugEl, document.getElementById('wf-modal-debug-content'));
}
function closeWfModal() {
const modal = document.getElementById('wf-modal');
if (!modal || modal.style.display === 'none') return;
modal.style.display = 'none';
// Destroy chart instances to free canvas memory
Object.values(modalCharts).forEach(c => c.destroy());
for (const k in modalCharts) delete modalCharts[k];
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'Escape') { closeWfModal(); return; }
if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
if (e.key === '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;
// 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>
<!-- ── Waveform Modal (DB history view) ──────────────────────────────────────
Opened by openDbWaveformModal(id). Click outside or press Esc to close. -->
<div id="wf-modal"
style="display:none; position:fixed; inset:0; z-index:1000;
background:rgba(1,4,9,0.88); align-items:flex-start;
justify-content:center; padding:24px; overflow:auto;"
onclick="if(event.target===this)closeWfModal()">
<div style="background:var(--surface); border:1px solid var(--border);
border-radius:8px; width:100%; max-width:1100px;
display:flex; flex-direction:column; max-height:calc(100vh - 48px);">
<!-- Header row -->
<div style="display:flex; align-items:center; padding:10px 16px;
border-bottom:1px solid var(--border); flex-shrink:0; gap:10px;">
<div id="wf-modal-title"
style="flex:1; font-size:12px; color:var(--text-dim); font-family:monospace; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">
</div>
<button onclick="closeWfModal()"
style="background:none; border:none; color:var(--text-dim); cursor:pointer;
font-size:20px; line-height:1; padding:0 2px; flex-shrink:0;"
title="Close (Esc)">×</button>
</div>
<!-- Peaks bar — reuses .peaks-bar styles from live Events tab -->
<div class="peaks-bar" id="wf-modal-peaks">
<div class="pk"><div class="pk-label">Tran</div><div class="pk-value pk-tran" id="wf-mpk-tran"></div></div>
<div class="pk"><div class="pk-label">Vert</div><div class="pk-value pk-vert" id="wf-mpk-vert"></div></div>
<div class="pk"><div class="pk-label">Long</div><div class="pk-value pk-long" id="wf-mpk-long"></div></div>
<div class="pk"><div class="pk-label">MicL</div><div class="pk-value pk-mic" id="wf-mpk-mic"></div></div>
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="wf-mpk-pvs"></div></div>
</div>
<!-- Debug panel (same as live debug panel, hidden by default) -->
<div id="wf-modal-debug"
style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('wf-modal-debug').style.display='none'">hide</span>
<div id="wf-modal-debug-content"></div>
</div>
<!-- Waveform area -->
<div style="flex:1; overflow-y:auto; min-height:200px;">
<div id="wf-modal-empty"
style="display:flex; flex-direction:column; align-items:center;
justify-content:center; padding:60px 20px; color:var(--text-dim); gap:12px;">
<p>Loading…</p>
</div>
<div id="wf-modal-charts" style="display:none;"></div>
</div>
</div>
</div>
</body>
</html>