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