Files
seismo-relay/sfm/sfm_webapp.html
T

1839 lines
70 KiB
HTML

<!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 { background: var(--green); color: #fff; margin-left: auto; }
#connect-btn:hover { background: var(--green-lt); }
/* ── 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; display: flex; 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; }
</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-group">
<label class="hdr">Device 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" />
</div>
<button class="btn" id="connect-btn" onclick="connectUnit()">Connect</button>
</header>
<!-- ── 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>
<!-- ── Tab bar ────────────────────────────────────────────────────── -->
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('device')">Device</button>
<button class="tab-btn" onclick="switchTab('events')">Events</button>
<button class="tab-btn" onclick="switchTab('config')">Config</button>
<button class="tab-btn" onclick="switchTab('history')">History</button>
<button class="tab-btn" onclick="switchTab('units')">Units</button>
<button class="tab-btn" onclick="switchTab('monlog')">Monitor Log</button>
<button class="tab-btn" onclick="switchTab('sessions')">Sessions</button>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Device
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-device" class="tab-pane active">
<div style="padding:24px">
<div id="no-device-msg">Connect to a device to view its information.</div>
<div id="device-detail" style="display:none">
<div class="dev-grid" id="dev-cards"></div>
<div class="dev-section-title">Compliance Config</div>
<div class="dev-table" id="compliance-table"></div>
<div class="dev-section-title">Project Info</div>
<div class="dev-table" id="project-table"></div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════
TAB: Events
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-events" class="tab-pane" style="display:flex; flex-direction:column; overflow:hidden;">
<div class="event-toolbar">
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled></button>
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button>
<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>Sample Rate</label>
<select id="cfg-sample-rate">
<option value="">— unchanged —</option>
<option value="1024">1024 sps (standard compliance)</option>
<option value="2048">2048 sps</option>
<option value="4096">4096 sps (high-speed)</option>
</select>
</div>
<div class="cfg-field">
<label>Record Time (seconds)</label>
<input type="number" id="cfg-record-time" step="0.5" min="0.5" max="60" placeholder="e.g. 3.0" />
<div class="hint">Waveform capture duration per trigger event</div>
</div>
<div class="cfg-field">
<label>Trigger Level — Geo (in/s)</label>
<input type="number" id="cfg-trigger" step="0.01" min="0.01" placeholder="e.g. 0.50" />
<div class="hint">Recording starts when any geo channel exceeds this level</div>
</div>
<div class="cfg-field">
<label>Alarm Level — Geo (in/s)</label>
<input type="number" id="cfg-alarm" step="0.01" min="0.01" placeholder="e.g. 1.00" />
<div class="hint">Alarm flagged when any geo channel exceeds this level</div>
</div>
<div class="cfg-field">
<label>Max Range — Geo (in/s)</label>
<input type="number" id="cfg-max-range" step="0.001" min="0.001" placeholder="e.g. 6.206" />
<div class="hint">Full-scale calibration constant — only change if you have a cal cert</div>
</div>
</div>
<!-- Project / operator strings -->
<div class="cfg-section">
<div class="cfg-section-title">Project Info</div>
<div class="cfg-field">
<label>Project</label>
<input type="text" id="cfg-project" maxlength="41" placeholder="Project description" />
</div>
<div class="cfg-field">
<label>Client</label>
<input type="text" id="cfg-client" maxlength="41" placeholder="Client / company name" />
</div>
<div class="cfg-field">
<label>Operator</label>
<input type="text" id="cfg-operator" maxlength="41" placeholder="Technician name" />
</div>
<div class="cfg-field">
<label>Sensor Location</label>
<input type="text" id="cfg-seis-loc" maxlength="41" placeholder="e.g. South Abutment" />
</div>
<div class="cfg-field">
<label>Notes</label>
<input type="text" id="cfg-notes" maxlength="41" placeholder="Extended notes" />
</div>
</div>
</div>
<div class="cfg-actions">
<button class="btn btn-ghost" id="cfg-read-btn" onclick="readConfig()" disabled>Read from Device</button>
<button class="btn btn-success" id="cfg-write-btn" onclick="writeConfig()" disabled>Write to Device</button>
<button class="btn btn-ghost" onclick="clearConfigForm()">Clear Form</button>
<span id="cfg-status"></span>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════
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</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>
<!-- ── Script ─────────────────────────────────────────────────────── -->
<script>
'use strict';
// ── State ──────────────────────────────────────────────────────────────────────
let unitInfo = null;
let eventList = [];
let currentEvent = 0;
let charts = {};
let geoRange = 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', Mic:'#bc8cff' };
// ── Helpers ────────────────────────────────────────────────────────────────────
function api() { return document.getElementById('api-base').value.replace(/\/$/, ''); }
function devHost() { return document.getElementById('dev-host').value.trim(); }
function devPort() { return document.getElementById('dev-port').value; }
function qs(sel, val) { const el = document.getElementById(sel); if (val !== undefined) el.value = val; return el; }
function setStatus(msg, cls = '') {
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = cls;
bar.textContent = msg;
}
function addPill(text) {
const bar = document.getElementById('status-bar');
const p = document.createElement('span');
p.className = 'meta-pill';
p.textContent = text;
bar.appendChild(p);
}
function setCfgStatus(msg, cls = '') {
const el = document.getElementById('cfg-status');
el.textContent = msg;
el.className = cls;
}
function deviceParams() {
return `host=${encodeURIComponent(devHost())}&tcp_port=${devPort()}`;
}
// ── Tab switching ──────────────────────────────────────────────────────────────
const TAB_NAMES = ['device','events','config','history','units','monlog','sessions'];
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach((b, i) => {
b.classList.toggle('active', TAB_NAMES[i] === name);
});
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.id === 'tab-events') {
pane.style.display = 'flex';
} else if (pane.classList.contains('db-tab-pane')) {
pane.style.display = 'flex';
} else {
pane.classList.add('active');
}
// Auto-load DB tabs on first switch
if (name === 'history') { if (!histLoaded) loadHistory(); }
if (name === 'units') { if (!unitsLoaded) loadUnits(); }
if (name === 'monlog') { if (!monlogLoaded) loadMonitorLog(); }
if (name === 'sessions') { if (!sessLoaded) loadSessions(); }
}
// ── Connect ────────────────────────────────────────────────────────────────────
async function connectUnit() {
if (!devHost()) { setStatus('Enter a device host first.', 'error'); return; }
const btn = document.getElementById('connect-btn');
btn.disabled = true;
btn.textContent = 'Connecting…';
setStatus('Connecting — reading device info…', 'loading');
try {
const r = await fetch(`${api()}/device/info?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
unitInfo = await r.json();
} catch (e) {
setStatus(`Connection failed: ${e.message}`, 'error');
btn.disabled = false; btn.textContent = 'Connect'; return;
}
setStatus('Fetching event list…', 'loading');
try {
const r = await fetch(`${api()}/device/events?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const evData = await r.json();
eventList = evData.events || [];
// Merge compliance from /device/events response (it re-reads it)
if (evData.device) unitInfo = { ...unitInfo, ...evData.device };
} catch (e) {
setStatus(`Event fetch failed: ${e.message}`, 'error');
btn.disabled = false; btn.textContent = 'Reconnect'; return;
}
populateDeviceBar();
populateDeviceTab();
populateEventChips();
populateConfigFromDeviceInfo();
document.getElementById('device-bar').style.display = 'flex';
document.getElementById('monitor-panel').style.display = 'flex';
document.getElementById('load-btn').disabled = eventList.length === 0;
document.getElementById('prev-btn').disabled = true;
document.getElementById('next-btn').disabled = eventList.length <= 1;
document.getElementById('cfg-read-btn').disabled = false;
document.getElementById('cfg-write-btn').disabled = false;
btn.disabled = false; btn.textContent = 'Reconnect';
setStatus(`Connected — ${eventList.length} event${eventList.length !== 1 ? 's' : ''} stored.`, 'ok');
// Fetch monitor status in background (non-blocking)
refreshMonitorStatus().catch(() => {});
const cc = unitInfo.compliance_config;
if (cc) {
if (cc.sample_rate) addPill(`${cc.sample_rate} sps`);
if (cc.trigger_level_geo != null) addPill(`trig ${cc.trigger_level_geo.toFixed(3)} in/s`);
}
}
// ── Device bar ─────────────────────────────────────────────────────────────────
function populateDeviceBar() {
qs('di-serial').textContent = unitInfo.serial || '—';
qs('di-fw').textContent = unitInfo.firmware_version || '—';
const cc = unitInfo.compliance_config || {};
qs('di-sr').textContent = cc.sample_rate ? `${cc.sample_rate} sps` : '—';
qs('di-rt').textContent = cc.record_time != null ? `${cc.record_time.toFixed(1)} s` : '—';
qs('di-trig').textContent = cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(3)} in/s` : '—';
qs('di-count').textContent = eventList.length;
qs('di-project').textContent = cc.project || '—';
qs('di-client').textContent = cc.client || '—';
qs('di-operator').textContent = cc.operator || '—';
geoRange = cc.max_range_geo ?? 6.206;
}
// ── Monitoring ─────────────────────────────────────────────────────────────────
async function refreshMonitorStatus() {
if (!devHost()) return null;
try {
const r = await fetch(`${api()}/device/monitor/status?${deviceParams()}`);
if (!r.ok) return null;
const s = await r.json();
updateMonitorPanel(s);
return s;
} catch (_) { return null; }
}
function updateMonitorPanel(s) {
const panel = document.getElementById('monitor-panel');
const badge = document.getElementById('mon-badge');
const batEl = document.getElementById('mon-battery');
const memTEl = document.getElementById('mon-mem-total');
const memFEl = document.getElementById('mon-mem-free');
const startB = document.getElementById('mon-start-btn');
const stopB = document.getElementById('mon-stop-btn');
if (s.is_monitoring) {
badge.textContent = 'MONITORING';
badge.className = 'mon-status-badge monitoring';
panel.className = 'monitoring';
startB.disabled = true;
stopB.disabled = false;
} else {
badge.textContent = 'IDLE';
badge.className = 'mon-status-badge idle';
panel.className = 'idle';
startB.disabled = false;
stopB.disabled = true;
}
// Battery and memory are available in both states — update if present,
// keep previous value if this was an optimistic update with no real data.
if (s.battery_v != null) batEl.textContent = `${s.battery_v.toFixed(2)} V`;
if (s.memory_total_kb != null) memTEl.textContent = `${s.memory_total_kb} KB`;
if (s.memory_free_kb != null) memFEl.textContent = `${s.memory_free_kb} KB`;
}
async function startMonitoring() {
if (!devHost()) return;
const btn = document.getElementById('mon-start-btn');
btn.disabled = true; btn.textContent = '…';
setStatus('Starting monitoring…', 'loading');
try {
const r = await fetch(`${api()}/device/monitor/start?${deviceParams()}`, { method: 'POST' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
// Optimistically show MONITORING immediately. The unit may run a ~40s on-device
// sensor check before fully entering monitor mode. We poll status every 5s for
// up to 60s, updating the badge when is_monitoring flips to true.
updateMonitorPanel({ is_monitoring: true });
setStatus('Monitoring started — sensor check in progress (~40s)…', 'loading');
btn.textContent = '▶ Start';
_pollMonitorConfirm(0);
} catch (e) {
setStatus(`Start monitoring failed: ${e.message}`, 'error');
btn.disabled = false;
btn.textContent = '▶ Start';
}
}
async function _pollMonitorConfirm(attempt) {
// Poll /device/monitor/status every 5s for up to 60s after startMonitoring().
// Updates the panel on each successful poll. Resolves once is_monitoring is true
// or after 12 attempts (60s), whichever comes first.
const MAX_ATTEMPTS = 12;
const INTERVAL_MS = 5000;
if (attempt >= MAX_ATTEMPTS) {
const s = await refreshMonitorStatus();
if (!s || !s.is_monitoring) {
setStatus('Warning: unit did not confirm monitoring state after 60s. Check device.', 'error');
} else {
setStatus('Monitoring active.', 'ok');
}
return;
}
await new Promise(res => setTimeout(res, INTERVAL_MS));
const s = await refreshMonitorStatus();
if (s && s.is_monitoring) {
setStatus('Monitoring active.', 'ok');
} else {
const elapsed = (attempt + 1) * 5;
setStatus(`Sensor check in progress… (${elapsed}s elapsed)`, 'loading');
_pollMonitorConfirm(attempt + 1);
}
}
async function stopMonitoring() {
if (!devHost()) return;
const btn = document.getElementById('mon-stop-btn');
btn.disabled = true; btn.textContent = '…';
setStatus('Stopping monitoring…', 'loading');
try {
const r = await fetch(`${api()}/device/monitor/stop?${deviceParams()}`, { method: 'POST' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
setStatus('Monitoring stopped.', 'ok');
await refreshMonitorStatus();
} catch (e) {
setStatus(`Stop monitoring failed: ${e.message}`, 'error');
btn.disabled = false;
}
btn.textContent = '■ Stop';
}
// ── Device tab ─────────────────────────────────────────────────────────────────
function populateDeviceTab() {
document.getElementById('no-device-msg').style.display = 'none';
document.getElementById('device-detail').style.display = 'block';
// Identity cards
const cards = document.getElementById('dev-cards');
cards.innerHTML = '';
const cardData = [
{ label:'Serial Number', value: unitInfo.serial || '—' },
{ label:'Firmware', value: unitInfo.firmware_version || '—' },
{ label:'DSP', value: unitInfo.dsp_version || '—' },
{ label:'Model', value: unitInfo.model || '—' },
{ label:'Manufacturer', value: unitInfo.manufacturer || '—' },
{ label:'Stored Events', value: eventList.length },
];
for (const {label, value} of cardData) {
const c = document.createElement('div');
c.className = 'dev-card';
c.innerHTML = `<div class="dev-card-label">${label}</div><div class="dev-card-value">${value}</div>`;
cards.appendChild(c);
}
// Compliance table
const cc = unitInfo.compliance_config || {};
const complianceRows = [
['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'],
['Record Time', cc.record_time != null ? `${cc.record_time.toFixed(2)} s` : '—'],
['Trigger Level (geo)', cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(4)} in/s` : '—'],
['Alarm Level (geo)', cc.alarm_level_geo != null ? `${cc.alarm_level_geo.toFixed(4)} in/s` : '—'],
['Max Range (geo)', cc.max_range_geo != null ? `${cc.max_range_geo.toFixed(4)} in/s` : '—'],
['Setup Name', cc.setup_name || '—'],
];
renderTable('compliance-table', complianceRows);
// Project table
const projectRows = [
['Project', cc.project || '—'],
['Client', cc.client || '—'],
['Operator', cc.operator || '—'],
['Sensor Location', cc.sensor_location || '—'],
['Notes', cc.notes || '—'],
];
renderTable('project-table', projectRows);
}
function renderTable(id, rows) {
const el = document.getElementById(id);
el.innerHTML = '';
for (const [label, value] of rows) {
const row = document.createElement('div');
row.className = 'dev-table-row';
row.innerHTML = `<div class="dtr-label">${label}</div><div class="dtr-value">${value}</div>`;
el.appendChild(row);
}
}
// ── Config form ────────────────────────────────────────────────────────────────
function populateConfigFromDeviceInfo() {
if (!unitInfo) return;
const cc = unitInfo.compliance_config || {};
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
if (cc.max_range_geo != null) qs('cfg-max-range',cc.max_range_geo.toFixed(4));
if (cc.project) qs('cfg-project', cc.project);
if (cc.client) qs('cfg-client', cc.client);
if (cc.operator) qs('cfg-operator', cc.operator);
if (cc.sensor_location) qs('cfg-seis-loc', cc.sensor_location);
if (cc.notes) qs('cfg-notes', cc.notes);
}
function clearConfigForm() {
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm','cfg-max-range',
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes']
.forEach(id => { const el = qs(id); el.tagName === 'SELECT' ? el.selectedIndex = 0 : el.value = ''; });
setCfgStatus('');
}
async function readConfig() {
if (!devHost()) { setCfgStatus('Not connected.', 'error'); return; }
setCfgStatus('Reading config from device…');
document.getElementById('cfg-read-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/info?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
unitInfo = await r.json();
populateConfigFromDeviceInfo();
populateDeviceBar();
populateDeviceTab();
setCfgStatus('Config loaded from device.', 'ok');
} catch(e) {
setCfgStatus(`Read failed: ${e.message}`, 'error');
} finally {
document.getElementById('cfg-read-btn').disabled = false;
}
}
async function writeConfig() {
if (!devHost()) { setCfgStatus('Not connected.', 'error'); return; }
// Build body — only include fields that have values
const body = {};
const sr = qs('cfg-sample-rate').value;
if (sr) body.sample_rate = parseInt(sr, 10);
const rt = qs('cfg-record-time').value;
if (rt) body.record_time = parseFloat(rt);
const trig = qs('cfg-trigger').value;
if (trig) body.trigger_level_geo = parseFloat(trig);
const alarm = qs('cfg-alarm').value;
if (alarm) body.alarm_level_geo = parseFloat(alarm);
const mr = qs('cfg-max-range').value;
if (mr) body.max_range_geo = parseFloat(mr);
const proj = qs('cfg-project').value.trim();
if (proj) body.project = proj;
const cli = qs('cfg-client').value.trim();
if (cli) body.client_name = cli;
const op = qs('cfg-operator').value.trim();
if (op) body.operator = op;
const sl = qs('cfg-seis-loc').value.trim();
if (sl) body.seis_loc = sl;
const notes = qs('cfg-notes').value.trim();
if (notes) body.notes = notes;
if (Object.keys(body).length === 0) {
setCfgStatus('No fields to write — fill in at least one field.', 'error');
return;
}
const fieldsStr = Object.keys(body).join(', ');
setCfgStatus(`Writing ${Object.keys(body).length} field(s)…`);
document.getElementById('cfg-write-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/config?${deviceParams()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const result = await r.json();
setCfgStatus(`Written: ${fieldsStr}`, 'ok');
// Re-read device info so Device tab and bar refresh
await readConfig();
} catch(e) {
setCfgStatus(`Write failed: ${e.message}`, 'error');
} finally {
document.getElementById('cfg-write-btn').disabled = false;
}
}
// ── Events ─────────────────────────────────────────────────────────────────────
function populateEventChips() {
const el = document.getElementById('event-chips');
el.innerHTML = '';
eventList.forEach((ev, i) => {
const chip = document.createElement('button');
chip.className = 'event-chip' + (i === 0 ? ' active' : '');
chip.textContent = ev.timestamp?.display ?? `Event ${ev.index}`;
chip.title = ev.record_type || '';
chip.onclick = () => selectEvent(i);
el.appendChild(chip);
});
// Show peak values from the event list header (no waveform needed)
if (eventList.length > 0) updatePeaksBar(eventList[0]);
}
function selectEvent(idx) {
currentEvent = idx;
document.querySelectorAll('.event-chip').forEach((c, i) => c.classList.toggle('active', i === idx));
document.getElementById('prev-btn').disabled = idx <= 0;
document.getElementById('next-btn').disabled = idx >= eventList.length - 1;
if (eventList[idx]) updatePeaksBar(eventList[idx]);
loadWaveform();
}
function stepEvent(delta) {
const next = Math.max(0, Math.min(eventList.length - 1, currentEvent + delta));
selectEvent(next);
}
function updatePeaksBar(ev) {
const pv = ev?.peak_values;
const bar = document.getElementById('peaks-bar');
if (!pv) { bar.classList.remove('visible'); return; }
bar.classList.add('visible');
qs('pk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
qs('pk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
qs('pk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
qs('pk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
qs('pk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
}
async function loadWaveform() {
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
const idx = currentEvent;
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;
}
function renderWaveform(data) {
const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const channels = data.channels || {};
// Status bar
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
bar.textContent = ts ? `Event #${data.index}${ts.display} ` : `Event #${data.index} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${data.rectime_seconds ?? '?'} s`);
if (decoded === 0) {
document.getElementById('empty-state').style.display = 'flex';
document.getElementById('empty-state').querySelector('p').textContent =
data.record_type === 'Waveform'
? 'No samples decoded — check server logs'
: `Record type "${data.record_type}" — waveform not supported yet`;
document.getElementById('charts').style.display = 'none';
Object.values(charts).forEach(c => c.destroy()); charts = {};
return;
}
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).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 = {};
const micPeakPsi = data.peak_values?.micl_psi ?? null;
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch];
if (!samples || samples.length === 0) continue;
const isGeo = ch !== 'Mic';
let plotData, peakLabel, yUnit, ttFmt, tickFmt;
if (isGeo) {
const scale = geoRange / 32767;
plotData = samples.map(s => s * scale);
// Use the device-recorded peak from the 0C waveform record — authoritative
// and matches Blastware. Computing from raw samples can catch rogue
// near-full-scale values from decoding artifacts.
const peakKey = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch];
const devicePeak = data.peak_values?.[peakKey] ?? null;
peakLabel = devicePeak != null ? `${devicePeak.toFixed(5)} in/s` : `${Math.max(...plotData.map(Math.abs)).toFixed(5)} in/s`;
yUnit = 'in/s';
ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4);
} else {
const peakCounts = Math.max(...samples.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0) ? Math.abs(micPeakPsi) / peakCounts : 1.0;
plotData = samples.map(s => s * micScale);
const peakPsi = Math.max(...plotData.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL`;
yUnit = 'psi';
ttFmt = v => `${v.toExponential(3)} psi`;
tickFmt = v => v.toExponential(1);
}
const MAX_PTS = 4000;
let rTimes = times, rData = plotData;
if (plotData.length > MAX_PTS) {
const step = Math.ceil(plotData.length / MAX_PTS);
rTimes = times.filter((_, i) => i % step === 0);
rData = plotData.filter((_, i) => i % step === 0);
}
const wrap = document.createElement('div');
wrap.className = 'chart-wrap';
const lbl = document.createElement('div');
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
lbl.textContent = `${ch} — peak ${peakLabel}`;
wrap.appendChild(lbl);
const cw = document.createElement('div');
cw.className = 'chart-canvas-wrap';
const canvas = document.createElement('canvas');
cw.appendChild(canvas); wrap.appendChild(cw); 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:yUnit, color:'#484f58', font:{size:10} } },
},
},
plugins: [{
id: 'triggerLine',
afterDraw(chart) {
const zeroIdx = rTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const { ctx, scales: {x, y} } = chart;
const px = x.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
ctx.strokeStyle = 'rgba(248,81,73,0.7)'; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
},
}],
});
}
}
// ── DB tabs ────────────────────────────────────────────────────────────────────
let histLoaded = false;
let unitsLoaded = false;
let monlogLoaded = false;
let sessLoaded = false;
// Shared serial filter options — populated from /db/units
const _unitSerials = new Set();
function _ppvClass(v) {
if (v == null) return '';
if (v >= 2.0) return 'ppv-high';
if (v >= 0.5) return 'ppv-warn';
return 'ppv-ok';
}
function _ppvFmt(v) {
return v != null ? v.toFixed(5) : '—';
}
function _fmtTs(ts) {
if (!ts) return '—';
// ts is ISO string; show date + time, strip trailing seconds if all zeros
const d = new Date(ts);
return d.toLocaleString();
}
function _fmtDur(sec) {
if (sec == null) return '—';
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function _populateSerialDropdown(selectId, currentVal) {
const sel = document.getElementById(selectId);
const prev = currentVal ?? sel.value;
sel.innerHTML = '<option value="">All units</option>';
for (const sn of [..._unitSerials].sort()) {
const opt = document.createElement('option');
opt.value = sn; opt.textContent = sn;
sel.appendChild(opt);
}
if (prev) sel.value = prev;
}
async function _fetchUnits() {
try {
const r = await fetch(`${api()}/db/units`);
if (!r.ok) return [];
return await r.json();
} catch { return []; }
}
// ── History tab ────────────────────────────────────────────────────────────────
async function loadHistory() {
histLoaded = true;
const serial = document.getElementById('hist-serial-filter').value;
const from_dt = document.getElementById('hist-from').value;
const to_dt = document.getElementById('hist-to').value;
const hideFT = document.getElementById('hist-hide-ft').checked;
let url = `${api()}/db/events?limit=500`;
if (serial) url += `&serial=${encodeURIComponent(serial)}`;
if (from_dt) url += `&from_dt=${encodeURIComponent(from_dt)}`;
if (to_dt) url += `&to_dt=${encodeURIComponent(to_dt + 'T23:59:59')}`;
let data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
document.getElementById('hist-count').textContent = `Error: ${e.message}`;
return;
}
let events = data.events || [];
if (hideFT) events = events.filter(ev => !ev.false_trigger);
// Update serial dropdowns with any new serials seen
events.forEach(ev => { if (ev.serial) _unitSerials.add(ev.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
document.getElementById('hist-count').textContent = `${events.length} event${events.length !== 1 ? 's' : ''}`;
const tbody = document.getElementById('hist-tbody');
tbody.innerHTML = '';
if (events.length === 0) {
document.getElementById('hist-empty').style.display = 'block';
document.getElementById('hist-table-wrap').style.display = 'none';
return;
}
document.getElementById('hist-empty').style.display = 'none';
document.getElementById('hist-table-wrap').style.display = 'block';
for (const ev of events) {
const tr = document.createElement('tr');
const pvs = ev.peak_vector_sum;
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
tr.innerHTML = `
<td>${_fmtTs(ev.timestamp)}</td>
<td class="td-key">${ev.serial ?? '—'}</td>
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
<td class="${_ppvClass(ev.vert_ppv)}">${_ppvFmt(ev.vert_ppv)}</td>
<td class="${_ppvClass(ev.long_ppv)}">${_ppvFmt(ev.long_ppv)}</td>
<td class="${_ppvClass(pvs)}">${_ppvFmt(pvs)}</td>
<td class="td-dim">${ev.mic_ppv != null ? ev.mic_ppv.toExponential(3) : '—'}</td>
<td class="td-text">${ev.project ?? '—'}</td>
<td class="td-text">${ev.client ?? '—'}</td>
<td class="td-dim">${ev.record_type ?? '—'}</td>
<td class="td-dim" style="font-size:10px">${ev.waveform_key ?? '—'}</td>
<td>${ev.false_trigger ? '<span class="ft-badge">FALSE</span>' : `<button class="ft-toggle-btn" onclick="toggleFalseTrigger(${ev.id}, this)" title="Flag as false trigger">Flag</button>`}</td>
`;
tbody.appendChild(tr);
}
}
async function toggleFalseTrigger(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`${api()}/db/events/${id}/false_trigger?value=true`, { method: 'PATCH' });
if (!r.ok) throw new Error(r.statusText);
btn.outerHTML = '<span class="ft-badge">FALSE</span>';
} catch (e) {
btn.disabled = false;
alert(`Failed to flag: ${e.message}`);
}
}
// ── Units tab ──────────────────────────────────────────────────────────────────
async function loadUnits() {
unitsLoaded = true;
const units = await _fetchUnits();
units.forEach(u => { if (u.serial) _unitSerials.add(u.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
document.getElementById('units-count').textContent = `${units.length} unit${units.length !== 1 ? 's' : ''}`;
const grid = document.getElementById('units-grid');
grid.innerHTML = '';
if (units.length === 0) {
document.getElementById('units-empty').style.display = 'block';
return;
}
document.getElementById('units-empty').style.display = 'none';
for (const u of units) {
const card = document.createElement('div');
card.className = 'unit-card';
card.title = 'Click to filter History by this unit';
card.onclick = () => {
_populateSerialDropdown('hist-serial-filter', u.serial);
switchTab('history');
loadHistory();
};
const lastSeen = u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—';
card.innerHTML = `
<div class="uc-serial">${u.serial}</div>
<div class="uc-stat"><span class="uc-label">Events</span><span class="uc-val">${u.total_events ?? 0}</span></div>
<div class="uc-stat"><span class="uc-label">Monitor entries</span><span class="uc-val">${u.total_monitor_entries ?? 0}</span></div>
<div class="uc-stat"><span class="uc-label">Sessions</span><span class="uc-val">${u.total_sessions ?? 0}</span></div>
<div class="uc-stat"><span class="uc-label">Last seen</span><span class="uc-val">${lastSeen}</span></div>
`;
grid.appendChild(card);
}
}
// ── Monitor Log tab ────────────────────────────────────────────────────────────
async function loadMonitorLog() {
monlogLoaded = true;
const serial = document.getElementById('monlog-serial-filter').value;
const from_dt = document.getElementById('monlog-from').value;
const to_dt = document.getElementById('monlog-to').value;
let url = `${api()}/db/monitor_log?`;
if (serial) url += `serial=${encodeURIComponent(serial)}&`;
if (from_dt) url += `from_dt=${encodeURIComponent(from_dt)}&`;
if (to_dt) url += `to_dt=${encodeURIComponent(to_dt + 'T23:59:59')}&`;
let data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
document.getElementById('monlog-count').textContent = `Error: ${e.message}`;
return;
}
const entries = data.entries || [];
entries.forEach(e => { if (e.serial) _unitSerials.add(e.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
document.getElementById('monlog-count').textContent = `${entries.length} entr${entries.length !== 1 ? 'ies' : 'y'}`;
const tbody = document.getElementById('monlog-tbody');
tbody.innerHTML = '';
if (entries.length === 0) {
document.getElementById('monlog-empty').style.display = 'block';
document.getElementById('monlog-table-wrap').style.display = 'none';
return;
}
document.getElementById('monlog-empty').style.display = 'none';
document.getElementById('monlog-table-wrap').style.display = 'block';
for (const e of entries) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${_fmtTs(e.start_time)}</td>
<td>${_fmtTs(e.stop_time)}</td>
<td>${_fmtDur(e.duration_seconds)}</td>
<td class="td-key">${e.serial ?? '—'}</td>
<td>${e.geo_threshold_ips != null ? e.geo_threshold_ips.toFixed(4) + ' in/s' : '—'}</td>
<td class="td-dim" style="font-size:10px">${e.key ?? '—'}</td>
`;
tbody.appendChild(tr);
}
}
// ── Sessions tab ───────────────────────────────────────────────────────────────
async function loadSessions() {
sessLoaded = true;
const serial = document.getElementById('sess-serial-filter').value;
let url = `${api()}/db/sessions?limit=200`;
if (serial) url += `&serial=${encodeURIComponent(serial)}`;
let data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
document.getElementById('sess-count').textContent = `Error: ${e.message}`;
return;
}
const sessions = data.sessions || [];
sessions.forEach(s => { if (s.serial) _unitSerials.add(s.serial); });
_populateSerialDropdown('hist-serial-filter');
_populateSerialDropdown('monlog-serial-filter');
_populateSerialDropdown('sess-serial-filter');
document.getElementById('sess-count').textContent = `${sessions.length} session${sessions.length !== 1 ? 's' : ''}`;
const tbody = document.getElementById('sess-tbody');
tbody.innerHTML = '';
if (sessions.length === 0) {
document.getElementById('sess-empty').style.display = 'block';
document.getElementById('sess-table-wrap').style.display = 'none';
return;
}
document.getElementById('sess-empty').style.display = 'none';
document.getElementById('sess-table-wrap').style.display = 'block';
for (const s of sessions) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${_fmtTs(s.session_time)}</td>
<td class="td-key">${s.serial ?? '—'}</td>
<td class="td-dim">${s.peer ?? '—'}</td>
<td>${s.events_downloaded ?? 0}</td>
<td>${s.monitor_entries ?? 0}</td>
<td>${s.duration_seconds != null ? s.duration_seconds.toFixed(1) : '—'}</td>
`;
tbody.appendChild(tr);
}
}
// ── 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;
['api-base','dev-host','dev-port'].forEach(id => {
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
});
</script>
</body>
</html>