feat: add waveform viewer endpoint and enhance UI with new tabs for history, units, monitor log, and sessions
This commit is contained in:
@@ -402,6 +402,12 @@ def webapp():
|
|||||||
return str(Path(__file__).parent / "sfm_webapp.html")
|
return str(Path(__file__).parent / "sfm_webapp.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/waveform", response_class=FileResponse)
|
||||||
|
def waveform_viewer():
|
||||||
|
"""Serve the standalone waveform viewer."""
|
||||||
|
return str(Path(__file__).parent / "waveform_viewer.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/device/info")
|
@app.get("/device/info")
|
||||||
def device_info(
|
def device_info(
|
||||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5, /dev/ttyUSB0)"),
|
port: Optional[str] = Query(None, description="Serial port (e.g. COM5, /dev/ttyUSB0)"),
|
||||||
|
|||||||
+560
-2
@@ -448,6 +448,143 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── DB tabs (History / Units / Monitor Log / Sessions) ── */
|
||||||
|
.db-tab-pane { padding: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.db-tab-pane.active { display: flex; }
|
||||||
|
|
||||||
|
.db-toolbar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border2);
|
||||||
|
padding: 8px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.db-toolbar label { color: var(--text-dim); font-size: 11px; white-space: nowrap; }
|
||||||
|
.db-toolbar input[type="text"],
|
||||||
|
.db-toolbar input[type="date"],
|
||||||
|
.db-toolbar select { font-size: 12px; padding: 4px 8px; }
|
||||||
|
.db-toolbar select#db-serial-filter { width: 120px; }
|
||||||
|
.db-toolbar input.date-input { width: 130px; }
|
||||||
|
.db-toolbar-spacer { flex: 1; }
|
||||||
|
.db-count-badge {
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-scroll { flex: 1; overflow-y: auto; padding: 14px 18px; }
|
||||||
|
|
||||||
|
.db-table-wrap {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
table.db-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
table.db-table thead th {
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 7px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.db-table tbody tr { border-bottom: 1px solid var(--border2); }
|
||||||
|
table.db-table tbody tr:last-child { border-bottom: none; }
|
||||||
|
table.db-table tbody tr:nth-child(even) { background: var(--surface); }
|
||||||
|
table.db-table tbody tr:hover { background: var(--surface2); }
|
||||||
|
table.db-table tbody td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
table.db-table tbody td.td-text {
|
||||||
|
font-family: inherit;
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
table.db-table tbody td.td-dim { color: var(--text-mute); }
|
||||||
|
table.db-table tbody td.td-key { color: var(--blue-lt); }
|
||||||
|
|
||||||
|
/* PPV color tiers: green < 0.5, amber < 2.0, red ≥ 2.0 in/s */
|
||||||
|
.ppv-ok { color: var(--green-lt); font-weight: 600; }
|
||||||
|
.ppv-warn { color: var(--yellow); font-weight: 600; }
|
||||||
|
.ppv-high { color: var(--red); font-weight: 600; }
|
||||||
|
|
||||||
|
.ft-badge {
|
||||||
|
background: rgba(248,81,73,0.15);
|
||||||
|
border: 1px solid rgba(248,81,73,0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--red);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.ft-toggle-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.ft-toggle-btn:hover { border-color: var(--red); color: var(--red); }
|
||||||
|
.ft-toggle-btn.flagged { border-color: var(--red); color: var(--red); background: rgba(248,81,73,0.1); }
|
||||||
|
|
||||||
|
.db-empty {
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Units tab cards */
|
||||||
|
.units-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.unit-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.unit-card:hover { border-color: var(--blue-lt); }
|
||||||
|
.unit-card .uc-serial {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--blue-lt);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.unit-card .uc-stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.unit-card .uc-label { font-size: 11px; color: var(--text-mute); }
|
||||||
|
.unit-card .uc-val { font-size: 12px; color: var(--text); font-family: monospace; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -538,6 +675,10 @@
|
|||||||
<button class="tab-btn active" onclick="switchTab('device')">Device</button>
|
<button class="tab-btn active" onclick="switchTab('device')">Device</button>
|
||||||
<button class="tab-btn" onclick="switchTab('events')">Events</button>
|
<button class="tab-btn" onclick="switchTab('events')">Events</button>
|
||||||
<button class="tab-btn" onclick="switchTab('config')">Config</button>
|
<button class="tab-btn" onclick="switchTab('config')">Config</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('history')">History</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('units')">Units</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('monlog')">Monitor Log</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('sessions')">Sessions</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════════════════════
|
<!-- ════════════════════════════════════════════════════════════════
|
||||||
@@ -678,6 +819,138 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════
|
||||||
|
TAB: History (events from DB)
|
||||||
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div id="tab-history" class="tab-pane db-tab-pane">
|
||||||
|
<div class="db-toolbar">
|
||||||
|
<label>Serial</label>
|
||||||
|
<select id="hist-serial-filter" onchange="loadHistory()">
|
||||||
|
<option value="">All units</option>
|
||||||
|
</select>
|
||||||
|
<label>From</label>
|
||||||
|
<input type="date" class="date-input" id="hist-from" onchange="loadHistory()" />
|
||||||
|
<label>To</label>
|
||||||
|
<input type="date" class="date-input" id="hist-to" onchange="loadHistory()" />
|
||||||
|
<label style="display:flex;align-items:center;gap:5px;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="hist-hide-ft" onchange="loadHistory()" />
|
||||||
|
Hide false triggers
|
||||||
|
</label>
|
||||||
|
<div class="db-toolbar-spacer"></div>
|
||||||
|
<button class="btn btn-ghost" onclick="loadHistory()">↻ Refresh</button>
|
||||||
|
<span class="db-count-badge" id="hist-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="db-scroll" id="hist-scroll">
|
||||||
|
<div class="db-empty" id="hist-empty" style="display:none">No events found.</div>
|
||||||
|
<div class="db-table-wrap" id="hist-table-wrap" style="display:none">
|
||||||
|
<table class="db-table" id="hist-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Serial</th>
|
||||||
|
<th>Tran (in/s)</th>
|
||||||
|
<th>Vert (in/s)</th>
|
||||||
|
<th>Long (in/s)</th>
|
||||||
|
<th>PVS (in/s)</th>
|
||||||
|
<th>Mic</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Key</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="hist-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════
|
||||||
|
TAB: Units
|
||||||
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div id="tab-units" class="tab-pane db-tab-pane">
|
||||||
|
<div class="db-toolbar">
|
||||||
|
<div class="db-toolbar-spacer"></div>
|
||||||
|
<button class="btn btn-ghost" onclick="loadUnits()">↻ Refresh</button>
|
||||||
|
<span class="db-count-badge" id="units-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="db-scroll">
|
||||||
|
<div class="db-empty" id="units-empty" style="display:none">No units in database yet.</div>
|
||||||
|
<div class="units-grid" id="units-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════
|
||||||
|
TAB: Monitor Log
|
||||||
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div id="tab-monlog" class="tab-pane db-tab-pane">
|
||||||
|
<div class="db-toolbar">
|
||||||
|
<label>Serial</label>
|
||||||
|
<select id="monlog-serial-filter" onchange="loadMonitorLog()">
|
||||||
|
<option value="">All units</option>
|
||||||
|
</select>
|
||||||
|
<label>From</label>
|
||||||
|
<input type="date" class="date-input" id="monlog-from" onchange="loadMonitorLog()" />
|
||||||
|
<label>To</label>
|
||||||
|
<input type="date" class="date-input" id="monlog-to" onchange="loadMonitorLog()" />
|
||||||
|
<div class="db-toolbar-spacer"></div>
|
||||||
|
<button class="btn btn-ghost" onclick="loadMonitorLog()">↻ Refresh</button>
|
||||||
|
<span class="db-count-badge" id="monlog-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="db-scroll" id="monlog-scroll">
|
||||||
|
<div class="db-empty" id="monlog-empty" style="display:none">No monitor log entries found.</div>
|
||||||
|
<div class="db-table-wrap" id="monlog-table-wrap" style="display:none">
|
||||||
|
<table class="db-table" id="monlog-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Start Time</th>
|
||||||
|
<th>Stop Time</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Serial</th>
|
||||||
|
<th>Geo Threshold</th>
|
||||||
|
<th>Key</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="monlog-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════
|
||||||
|
TAB: Sessions
|
||||||
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div id="tab-sessions" class="tab-pane db-tab-pane">
|
||||||
|
<div class="db-toolbar">
|
||||||
|
<label>Serial</label>
|
||||||
|
<select id="sess-serial-filter" onchange="loadSessions()">
|
||||||
|
<option value="">All units</option>
|
||||||
|
</select>
|
||||||
|
<div class="db-toolbar-spacer"></div>
|
||||||
|
<button class="btn btn-ghost" onclick="loadSessions()">↻ Refresh</button>
|
||||||
|
<span class="db-count-badge" id="sess-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="db-scroll" id="sess-scroll">
|
||||||
|
<div class="db-empty" id="sess-empty" style="display:none">No ACH sessions recorded yet.</div>
|
||||||
|
<div class="db-table-wrap" id="sess-table-wrap" style="display:none">
|
||||||
|
<table class="db-table" id="sess-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session Time</th>
|
||||||
|
<th>Serial</th>
|
||||||
|
<th>Peer</th>
|
||||||
|
<th>Events DL'd</th>
|
||||||
|
<th>Monitor Entries</th>
|
||||||
|
<th>Duration (s)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sess-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Script ─────────────────────────────────────────────────────── -->
|
<!-- ── Script ─────────────────────────────────────────────────────── -->
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -721,10 +994,11 @@ function deviceParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab switching ──────────────────────────────────────────────────────────────
|
// ── Tab switching ──────────────────────────────────────────────────────────────
|
||||||
|
const TAB_NAMES = ['device','events','config','history','units','monlog','sessions'];
|
||||||
|
|
||||||
function switchTab(name) {
|
function switchTab(name) {
|
||||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||||
const names = ['device','events','config'];
|
b.classList.toggle('active', TAB_NAMES[i] === name);
|
||||||
b.classList.toggle('active', names[i] === name);
|
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.tab-pane').forEach(p => {
|
document.querySelectorAll('.tab-pane').forEach(p => {
|
||||||
p.classList.remove('active');
|
p.classList.remove('active');
|
||||||
@@ -733,9 +1007,16 @@ function switchTab(name) {
|
|||||||
const pane = document.getElementById(`tab-${name}`);
|
const pane = document.getElementById(`tab-${name}`);
|
||||||
if (pane.id === 'tab-events') {
|
if (pane.id === 'tab-events') {
|
||||||
pane.style.display = 'flex';
|
pane.style.display = 'flex';
|
||||||
|
} else if (pane.classList.contains('db-tab-pane')) {
|
||||||
|
pane.style.display = 'flex';
|
||||||
} else {
|
} else {
|
||||||
pane.classList.add('active');
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Connect ────────────────────────────────────────────────────────────────────
|
||||||
@@ -1261,6 +1542,283 @@ function renderWaveform(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DB tabs ────────────────────────────────────────────────────────────────────
|
||||||
|
let histLoaded = false;
|
||||||
|
let unitsLoaded = false;
|
||||||
|
let monlogLoaded = false;
|
||||||
|
let sessLoaded = false;
|
||||||
|
|
||||||
|
// Shared serial filter options — populated from /db/units
|
||||||
|
const _unitSerials = new Set();
|
||||||
|
|
||||||
|
function _ppvClass(v) {
|
||||||
|
if (v == null) return '';
|
||||||
|
if (v >= 2.0) return 'ppv-high';
|
||||||
|
if (v >= 0.5) return 'ppv-warn';
|
||||||
|
return 'ppv-ok';
|
||||||
|
}
|
||||||
|
function _ppvFmt(v) {
|
||||||
|
return v != null ? v.toFixed(5) : '—';
|
||||||
|
}
|
||||||
|
function _fmtTs(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
// ts is ISO string; show date + time, strip trailing seconds if all zeros
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
function _fmtDur(sec) {
|
||||||
|
if (sec == null) return '—';
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
if (m > 0) return `${m}m ${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _populateSerialDropdown(selectId, currentVal) {
|
||||||
|
const sel = document.getElementById(selectId);
|
||||||
|
const prev = currentVal ?? sel.value;
|
||||||
|
sel.innerHTML = '<option value="">All units</option>';
|
||||||
|
for (const sn of [..._unitSerials].sort()) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = sn; opt.textContent = sn;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (prev) sel.value = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchUnits() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${api()}/db/units`);
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return await r.json();
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History tab ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadHistory() {
|
||||||
|
histLoaded = true;
|
||||||
|
const serial = document.getElementById('hist-serial-filter').value;
|
||||||
|
const from_dt = document.getElementById('hist-from').value;
|
||||||
|
const to_dt = document.getElementById('hist-to').value;
|
||||||
|
const hideFT = document.getElementById('hist-hide-ft').checked;
|
||||||
|
|
||||||
|
let url = `${api()}/db/events?limit=500`;
|
||||||
|
if (serial) url += `&serial=${encodeURIComponent(serial)}`;
|
||||||
|
if (from_dt) url += `&from_dt=${encodeURIComponent(from_dt)}`;
|
||||||
|
if (to_dt) url += `&to_dt=${encodeURIComponent(to_dt + 'T23:59:59')}`;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
data = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('hist-count').textContent = `Error: ${e.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = data.events || [];
|
||||||
|
if (hideFT) events = events.filter(ev => !ev.false_trigger);
|
||||||
|
|
||||||
|
// Update serial dropdowns with any new serials seen
|
||||||
|
events.forEach(ev => { if (ev.serial) _unitSerials.add(ev.serial); });
|
||||||
|
_populateSerialDropdown('hist-serial-filter');
|
||||||
|
_populateSerialDropdown('monlog-serial-filter');
|
||||||
|
_populateSerialDropdown('sess-serial-filter');
|
||||||
|
|
||||||
|
document.getElementById('hist-count').textContent = `${events.length} event${events.length !== 1 ? 's' : ''}`;
|
||||||
|
const tbody = document.getElementById('hist-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
document.getElementById('hist-empty').style.display = 'block';
|
||||||
|
document.getElementById('hist-table-wrap').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('hist-empty').style.display = 'none';
|
||||||
|
document.getElementById('hist-table-wrap').style.display = 'block';
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const pvs = ev.peak_vector_sum;
|
||||||
|
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${_fmtTs(ev.timestamp)}</td>
|
||||||
|
<td class="td-key">${ev.serial ?? '—'}</td>
|
||||||
|
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
|
||||||
|
<td class="${_ppvClass(ev.vert_ppv)}">${_ppvFmt(ev.vert_ppv)}</td>
|
||||||
|
<td class="${_ppvClass(ev.long_ppv)}">${_ppvFmt(ev.long_ppv)}</td>
|
||||||
|
<td class="${_ppvClass(pvs)}">${_ppvFmt(pvs)}</td>
|
||||||
|
<td class="td-dim">${ev.mic_ppv != null ? ev.mic_ppv.toExponential(3) : '—'}</td>
|
||||||
|
<td class="td-text">${ev.project ?? '—'}</td>
|
||||||
|
<td class="td-text">${ev.client ?? '—'}</td>
|
||||||
|
<td class="td-dim">${ev.record_type ?? '—'}</td>
|
||||||
|
<td class="td-dim" style="font-size:10px">${ev.waveform_key ?? '—'}</td>
|
||||||
|
<td>${ev.false_trigger ? '<span class="ft-badge">FALSE</span>' : `<button class="ft-toggle-btn" onclick="toggleFalseTrigger(${ev.id}, this)" title="Flag as false trigger">Flag</button>`}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFalseTrigger(id, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${api()}/db/events/${id}/false_trigger?value=true`, { method: 'PATCH' });
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
btn.outerHTML = '<span class="ft-badge">FALSE</span>';
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
alert(`Failed to flag: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Units tab ──────────────────────────────────────────────────────────────────
|
||||||
|
async function loadUnits() {
|
||||||
|
unitsLoaded = true;
|
||||||
|
const units = await _fetchUnits();
|
||||||
|
|
||||||
|
units.forEach(u => { if (u.serial) _unitSerials.add(u.serial); });
|
||||||
|
_populateSerialDropdown('hist-serial-filter');
|
||||||
|
_populateSerialDropdown('monlog-serial-filter');
|
||||||
|
_populateSerialDropdown('sess-serial-filter');
|
||||||
|
|
||||||
|
document.getElementById('units-count').textContent = `${units.length} unit${units.length !== 1 ? 's' : ''}`;
|
||||||
|
const grid = document.getElementById('units-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
if (units.length === 0) {
|
||||||
|
document.getElementById('units-empty').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('units-empty').style.display = 'none';
|
||||||
|
|
||||||
|
for (const u of units) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'unit-card';
|
||||||
|
card.title = 'Click to filter History by this unit';
|
||||||
|
card.onclick = () => {
|
||||||
|
_populateSerialDropdown('hist-serial-filter', u.serial);
|
||||||
|
switchTab('history');
|
||||||
|
loadHistory();
|
||||||
|
};
|
||||||
|
const lastSeen = u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="uc-serial">${u.serial}</div>
|
||||||
|
<div class="uc-stat"><span class="uc-label">Events</span><span class="uc-val">${u.total_events ?? 0}</span></div>
|
||||||
|
<div class="uc-stat"><span class="uc-label">Monitor entries</span><span class="uc-val">${u.total_monitor_entries ?? 0}</span></div>
|
||||||
|
<div class="uc-stat"><span class="uc-label">Sessions</span><span class="uc-val">${u.total_sessions ?? 0}</span></div>
|
||||||
|
<div class="uc-stat"><span class="uc-label">Last seen</span><span class="uc-val">${lastSeen}</span></div>
|
||||||
|
`;
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Monitor Log tab ────────────────────────────────────────────────────────────
|
||||||
|
async function loadMonitorLog() {
|
||||||
|
monlogLoaded = true;
|
||||||
|
const serial = document.getElementById('monlog-serial-filter').value;
|
||||||
|
const from_dt = document.getElementById('monlog-from').value;
|
||||||
|
const to_dt = document.getElementById('monlog-to').value;
|
||||||
|
|
||||||
|
let url = `${api()}/db/monitor_log?`;
|
||||||
|
if (serial) url += `serial=${encodeURIComponent(serial)}&`;
|
||||||
|
if (from_dt) url += `from_dt=${encodeURIComponent(from_dt)}&`;
|
||||||
|
if (to_dt) url += `to_dt=${encodeURIComponent(to_dt + 'T23:59:59')}&`;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
data = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('monlog-count').textContent = `Error: ${e.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = data.entries || [];
|
||||||
|
entries.forEach(e => { if (e.serial) _unitSerials.add(e.serial); });
|
||||||
|
_populateSerialDropdown('hist-serial-filter');
|
||||||
|
_populateSerialDropdown('monlog-serial-filter');
|
||||||
|
_populateSerialDropdown('sess-serial-filter');
|
||||||
|
|
||||||
|
document.getElementById('monlog-count').textContent = `${entries.length} entr${entries.length !== 1 ? 'ies' : 'y'}`;
|
||||||
|
const tbody = document.getElementById('monlog-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
document.getElementById('monlog-empty').style.display = 'block';
|
||||||
|
document.getElementById('monlog-table-wrap').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('monlog-empty').style.display = 'none';
|
||||||
|
document.getElementById('monlog-table-wrap').style.display = 'block';
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${_fmtTs(e.start_time)}</td>
|
||||||
|
<td>${_fmtTs(e.stop_time)}</td>
|
||||||
|
<td>${_fmtDur(e.duration_seconds)}</td>
|
||||||
|
<td class="td-key">${e.serial ?? '—'}</td>
|
||||||
|
<td>${e.geo_threshold_ips != null ? e.geo_threshold_ips.toFixed(4) + ' in/s' : '—'}</td>
|
||||||
|
<td class="td-dim" style="font-size:10px">${e.key ?? '—'}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sessions tab ───────────────────────────────────────────────────────────────
|
||||||
|
async function loadSessions() {
|
||||||
|
sessLoaded = true;
|
||||||
|
const serial = document.getElementById('sess-serial-filter').value;
|
||||||
|
|
||||||
|
let url = `${api()}/db/sessions?limit=200`;
|
||||||
|
if (serial) url += `&serial=${encodeURIComponent(serial)}`;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
data = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('sess-count').textContent = `Error: ${e.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = data.sessions || [];
|
||||||
|
sessions.forEach(s => { if (s.serial) _unitSerials.add(s.serial); });
|
||||||
|
_populateSerialDropdown('hist-serial-filter');
|
||||||
|
_populateSerialDropdown('monlog-serial-filter');
|
||||||
|
_populateSerialDropdown('sess-serial-filter');
|
||||||
|
|
||||||
|
document.getElementById('sess-count').textContent = `${sessions.length} session${sessions.length !== 1 ? 's' : ''}`;
|
||||||
|
const tbody = document.getElementById('sess-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
document.getElementById('sess-empty').style.display = 'block';
|
||||||
|
document.getElementById('sess-table-wrap').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('sess-empty').style.display = 'none';
|
||||||
|
document.getElementById('sess-table-wrap').style.display = 'block';
|
||||||
|
|
||||||
|
for (const s of sessions) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${_fmtTs(s.session_time)}</td>
|
||||||
|
<td class="td-key">${s.serial ?? '—'}</td>
|
||||||
|
<td class="td-dim">${s.peer ?? '—'}</td>
|
||||||
|
<td>${s.events_downloaded ?? 0}</td>
|
||||||
|
<td>${s.monitor_entries ?? 0}</td>
|
||||||
|
<td>${s.duration_seconds != null ? s.duration_seconds.toFixed(1) : '—'}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
|
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
<h1>SFM Waveform Viewer</h1>
|
<h1>SFM Waveform Viewer</h1>
|
||||||
<div class="conn-group">
|
<div class="conn-group">
|
||||||
<label>API</label>
|
<label>API</label>
|
||||||
<input type="text" id="api-base" value="http://localhost:8200" style="width:180px" />
|
<input type="text" id="api-base" style="width:180px" />
|
||||||
</div>
|
</div>
|
||||||
<div class="conn-group">
|
<div class="conn-group">
|
||||||
<label>Device host</label>
|
<label>Device host</label>
|
||||||
@@ -588,6 +588,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-detect API base from wherever this page was served from
|
||||||
|
document.getElementById('api-base').value = window.location.origin;
|
||||||
|
|
||||||
// Allow Enter key on connection inputs to trigger connect
|
// Allow Enter key on connection inputs to trigger connect
|
||||||
['api-base', 'dev-host', 'dev-tcp-port'].forEach(id => {
|
['api-base', 'dev-host', 'dev-tcp-port'].forEach(id => {
|
||||||
document.getElementById(id).addEventListener('keydown', e => {
|
document.getElementById(id).addEventListener('keydown', e => {
|
||||||
|
|||||||
Reference in New Issue
Block a user