Files
seismo-relay/sfm/sfm_webapp.html
T

2805 lines
110 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); }
.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;
}
#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>
<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>
</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>
<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 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 ─────────────────────────────────────────────────────────
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('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 ────────────────────────────────────────────────────────────────
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;
tr.classList.add('clickable');
tr.title = 'Click to review (open sidecar editor)';
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 '—';
// BW (.AB0*/.N00) stores mic_ppv as psi → convert to dBL.
// Thor IDF (.IDFH/.IDFW) already stores dBL → display directly.
const fn = (ev.blastware_filename || '').toUpperCase();
const isThor = fn.endsWith('.IDFH') || fn.endsWith('.IDFW');
if (isThor) 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);
}
}
// ── 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 = '';
try {
const r = await fetch(`${api()}/db/events/${eventId}/sidecar`);
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText);
}
const data = await r.json();
_scCurrentSidecar = data;
_renderSidecar(data);
document.getElementById('sc-status').textContent = '';
} catch (e) {
document.getElementById('sc-status').className = 'sc-status error';
document.getElementById('sc-status').textContent = `Load failed: ${e.message}`;
}
}
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);
};
const fmtMic = v => {
if (v == null) return '—';
const n = Number(v);
if (!isFinite(n) || n <= 0) return '—';
// Source-aware: Thor IDF imports store mic as dB(L); BW imports store
// it as psi. `src` is in the outer scope from _renderSidecar().
if ((src.kind || '') === 'idf-import') 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 || '—';
document.getElementById('sc-f-ts').textContent = 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 || '—';
document.getElementById('sc-f-cap').textContent = 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;
}
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;
// 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">
<div class="sc-section">
<h4>Event</h4>
<dl class="sc-grid">
<dt>Serial</dt> <dd id="sc-f-serial"></dd>
<dt>Timestamp</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>BW filename</dt> <dd id="sc-f-bw"></dd>
<dt>BW filesize</dt> <dd id="sc-f-bwsize"></dd>
<dt>BW sha256</dt> <dd id="sc-f-sha"></dd>
<dt>Source kind</dt> <dd id="sc-f-src"></dd>
<dt>Captured 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" onclick="closeSidecarModal()">Cancel</button>
<button class="btn" id="sc-save-btn" onclick="saveSidecarReview()">Save</button>
</div>
</div>
</div>
</body>
</html>