Files
terra-view/templates/sfm.html
T
serversdown ec661ee079 refactor(sfm): drop ACH/monitor/live-device UI; scope SFM tab to watcher-forwarded events
The /sfm page was originally designed around a Python ACH-server
replacement that would land call-home sessions, monitor-log intervals,
and live-device control alongside triggered events. That work is
paused — deployment uses Blastware's official ACH server and series3-
watcher forwards events to SFM's /db/import/blastware_file. The
sessions/monitor-log/live-device surfaces have no path to populate
under this architecture and were rendering 0/0 everywhere.

Removed (UI only — SFM backend untouched):
- KPI tiles "Monitor Intervals" + "ACH Sessions" (always 0 under
  watcher-forward pipeline)
- Tabs Monitor Log / ACH Sessions / Live Device + their loaders
- Units card columns total_monitor_entries + total_sessions
- Orphaned helpers fmtDuration / fmtBytes
- Live-device state vars + status poll timer
- Subtitle and empty-state copy updated to match reality
- Sidebar: "SFM Live Data" -> "SFM Events"

SFM-side code (ach_sessions/monitor_log tables, /db/sessions,
/db/monitor_log, /device/* endpoints, protocol RE library) is
preserved intact — re-surfacing the tabs later is a UI-only revert.
backend/routers/sfm.py catch-all proxy unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 19:36:38 +00:00

475 lines
25 KiB
HTML

{% extends "base.html" %}
{% block title %}SFM Event Data - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">SFM Event Data</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Blastware ACH events forwarded by series3-watcher</p>
</div>
<div class="flex items-center gap-3">
<span id="sfm-status-badge" class="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Checking SFM…
</span>
<button onclick="checkSFMHealth()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Refresh
</button>
</div>
</div>
<!-- Stats bar -->
<div id="sfm-stats" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Known Units</span>
<span id="stat-units" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
<span id="stat-events" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
</div>
<!-- Tab navigation -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex overflow-x-auto" aria-label="Tabs">
<button onclick="switchTab('events')" id="tab-events"
class="sfm-tab active-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
Events
</button>
<button onclick="switchTab('units')" id="tab-units"
class="sfm-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
Units
</button>
</nav>
</div>
<!-- ── Events Tab ─────────────────────────────────────────────────────── -->
<div id="panel-events" class="sfm-panel p-6">
<!-- Filters -->
<div class="flex flex-wrap items-end gap-3 mb-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Unit Serial</label>
<select id="ev-serial" onchange="loadEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">All Units</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="datetime-local" id="ev-from" onchange="loadEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="datetime-local" id="ev-to" onchange="loadEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
<select id="ev-false-trigger" onchange="loadEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">All Events</option>
<option value="false">Real Events Only</option>
<option value="true">False Triggers Only</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
<select id="ev-limit" onchange="loadEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="100">100</option>
<option value="250">250</option>
<option value="500" selected>500</option>
</select>
</div>
<button onclick="clearEventFilters()"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear Filters
</button>
<button onclick="loadEvents()"
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-orange-600">
↻ Reload
</button>
</div>
<!-- Events table -->
<div id="events-container" class="overflow-x-auto">
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
Loading events…
</div>
</div>
</div>
<!-- ── Units Tab ─────────────────────────────────────────────────────── -->
<div id="panel-units" class="sfm-panel hidden p-6">
<div id="units-container">
<div class="text-center py-12 text-gray-500">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
Loading units…
</div>
</div>
</div>
</div><!-- end tab container -->
<!-- Event detail modal -->
<div id="event-modal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/60" onclick="closeEventModal()"></div>
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-2xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[85vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="modal-title">Event Detail</h3>
<button onclick="closeEventModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div id="modal-content"></div>
</div>
</div>
<style>
.sfm-tab {
color: #6b7280;
border-color: transparent;
}
.sfm-tab:hover {
color: #374151;
border-color: #d1d5db;
}
.dark .sfm-tab {
color: #9ca3af;
}
.dark .sfm-tab:hover {
color: #f3f4f6;
border-color: #4b5563;
}
.sfm-tab.active-tab {
color: #f48b1c;
border-color: #f48b1c;
}
.dark .sfm-tab.active-tab {
color: #f48b1c;
border-color: #f48b1c;
}
.sfm-panel { display: block; }
.sfm-panel.hidden { display: none; }
/* PPV colour thresholds */
.ppv-low { color: #10b981; }
.ppv-mid { color: #f59e0b; }
.ppv-high { color: #ef4444; font-weight: 600; }
</style>
<script>
// ── State ───────────────────────────────────────────────────────────────────
let _knownSerials = [];
// ── Tabs ────────────────────────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.sfm-tab').forEach(t => t.classList.remove('active-tab'));
document.querySelectorAll('.sfm-panel').forEach(p => p.classList.add('hidden'));
document.getElementById('tab-' + name).classList.add('active-tab');
document.getElementById('panel-' + name).classList.remove('hidden');
// Lazy-load tabs on first visit
if (name === 'units' && document.getElementById('units-container').innerHTML.includes('Loading')) loadUnits();
}
// ── SFM health ───────────────────────────────────────────────────────────────
async function checkSFMHealth() {
const badge = document.getElementById('sfm-status-badge');
badge.textContent = 'Checking…';
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
try {
const r = await fetch('/api/sfm/health');
const d = await r.json();
if (d.sfm_status === 'connected') {
badge.textContent = '● SFM Connected';
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
loadStats();
} else {
badge.textContent = '● SFM Offline';
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
}
} catch (e) {
badge.textContent = '● SFM Error';
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
}
}
// ── Stats ────────────────────────────────────────────────────────────────────
async function loadStats() {
try {
const r = await fetch('/api/sfm/db/units');
if (!r.ok) return;
const units = await r.json();
_knownSerials = units.map(u => u.serial);
const totalEvents = units.reduce((s, u) => s + (u.total_events || 0), 0);
document.getElementById('stat-units').textContent = units.length;
document.getElementById('stat-events').textContent = totalEvents.toLocaleString();
// Populate serial dropdowns
['ev-serial'].forEach(id => {
const sel = document.getElementById(id);
const cur = sel.value;
while (sel.options.length > 1) sel.remove(1);
_knownSerials.forEach(s => {
const opt = document.createElement('option');
opt.value = s; opt.textContent = s;
sel.add(opt);
});
if (cur) sel.value = cur;
});
} catch (e) {
console.error('Failed to load stats:', e);
}
}
// ── Events tab ───────────────────────────────────────────────────────────────
async function loadEvents() {
const container = document.getElementById('events-container');
container.innerHTML = '<div class="text-center py-8 text-gray-500"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
const params = new URLSearchParams();
const serial = document.getElementById('ev-serial').value;
const from = document.getElementById('ev-from').value;
const to = document.getElementById('ev-to').value;
const ft = document.getElementById('ev-false-trigger').value;
const limit = document.getElementById('ev-limit').value;
if (serial) params.set('serial', serial);
if (from) params.set('from_dt', from.replace('T', ' '));
if (to) params.set('to_dt', to.replace('T', ' '));
if (ft) params.set('false_trigger', ft);
params.set('limit', limit);
try {
const r = await fetch('/api/sfm/db/events?' + params.toString());
if (!r.ok) { throw new Error('HTTP ' + r.status); }
const d = await r.json();
renderEventsTable(d.events, d.count, container);
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load events: ${e.message}</div>`;
}
}
function renderEventsTable(events, total, container) {
if (!events || events.length === 0) {
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><p class="text-sm">No events found matching the current filters.</p></div>';
return;
}
const rows = events.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = fmtPPV(ev.tran_ppv);
const vert = fmtPPV(ev.vert_ppv);
const lng = fmtPPV(ev.long_ppv);
const pvs = fmtPPV(ev.peak_vector_sum);
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
const ft = ev.false_trigger ? `<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">FT</span>` : '';
const proj = ev.project ? `<span class="truncate max-w-[120px] inline-block" title="${esc(ev.project)}">${esc(ev.project)}</span>` : '<span class="text-gray-400">—</span>';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail(${JSON.stringify(JSON.stringify(ev))})">
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
<td class="px-4 py-2.5 text-sm font-mono font-medium text-seismo-orange">${esc(ev.serial)}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[140px]">${proj}</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.tran_ppv)}">${tran}</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.vert_ppv)}">${vert}</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
<td class="px-4 py-2.5 text-sm">
${ft}
<button onclick="event.stopPropagation(); toggleFalseTrigger('${ev.id}', ${ev.false_trigger ? 'false' : 'true'}, this)"
class="ml-1 px-2 py-0.5 rounded text-xs ${ev.false_trigger ? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 hover:bg-yellow-100' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-yellow-50'}"
title="${ev.false_trigger ? 'Clear false trigger' : 'Mark as false trigger'}">
${ev.false_trigger ? '✕ FT' : 'Flag FT'}
</button>
</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">Showing ${events.length} of ${total} events</div>
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Project</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
}
function fmtPPV(v) {
if (v == null) return '—';
return v.toFixed(4);
}
function ppvClass(v) {
if (v == null) return 'text-gray-400';
if (v < 0.5) return 'ppv-low';
if (v < 2.0) return 'ppv-mid';
return 'ppv-high';
}
function clearEventFilters() {
document.getElementById('ev-serial').value = '';
document.getElementById('ev-from').value = '';
document.getElementById('ev-to').value = '';
document.getElementById('ev-false-trigger').value = '';
document.getElementById('ev-limit').value = '500';
loadEvents();
}
async function toggleFalseTrigger(id, newValue, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch(`/api/sfm/db/events/${id}/false_trigger?value=${newValue}`, { method: 'PATCH' });
if (r.ok) {
// Refresh the table after short delay
setTimeout(loadEvents, 300);
} else {
btn.textContent = 'Error';
btn.disabled = false;
}
} catch (e) {
btn.textContent = 'Error';
btn.disabled = false;
}
}
// ── Event detail modal ───────────────────────────────────────────────────────
function showEventDetail(jsonStr) {
const ev = JSON.parse(jsonStr);
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
document.getElementById('modal-title').textContent = `Event — ${esc(ev.serial)} @ ${ts}`;
document.getElementById('modal-content').innerHTML = `
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
<div><span class="text-gray-500">Serial</span><span class="ml-2 font-medium">${esc(ev.serial)}</span></div>
<div><span class="text-gray-500">Key</span><span class="ml-2 font-mono text-xs">${esc(ev.waveform_key)}</span></div>
<div><span class="text-gray-500">Timestamp</span><span class="ml-2 font-medium">${ts}</span></div>
<div><span class="text-gray-500">Sample Rate</span><span class="ml-2 font-medium">${ev.sample_rate || '—'} sps</span></div>
<div><span class="text-gray-500">Record Type</span><span class="ml-2 font-medium">${ev.record_type || '—'}</span></div>
<div><span class="text-gray-500">False Trigger</span><span class="ml-2 font-medium">${ev.false_trigger ? '⚠ Yes' : 'No'}</span></div>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
<div><span class="text-gray-500">Project</span><span class="ml-2 font-medium">${esc(ev.project || '—')}</span></div>
<div><span class="text-gray-500">Client</span><span class="ml-2 font-medium">${esc(ev.client || '—')}</span></div>
<div><span class="text-gray-500">Operator</span><span class="ml-2 font-medium">${esc(ev.operator || '—')}</span></div>
<div><span class="text-gray-500">Sensor Loc</span><span class="ml-2 font-medium">${esc(ev.sensor_location || '—')}</span></div>
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Peak Particle Velocity</h4>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
${ppvCard('Transverse', ev.tran_ppv)}
${ppvCard('Vertical', ev.vert_ppv)}
${ppvCard('Longitudinal', ev.long_ppv)}
${ppvCard('Peak Vector Sum', ev.peak_vector_sum, true)}
</div>
${ev.mic_ppv != null ? `<div class="mt-3 text-sm text-center text-gray-600 dark:text-gray-400">Mic: <span class="font-mono font-medium">${ev.mic_ppv.toFixed(3)}</span></div>` : ''}
</div>`;
document.getElementById('event-modal').classList.remove('hidden');
}
function ppvCard(label, v, bold = false) {
const val = v != null ? v.toFixed(4) : '—';
const cls = ppvClass(v) + (bold ? ' text-lg' : '');
return `<div>
<div class="text-xs text-gray-500 mb-1">${label}</div>
<div class="font-mono font-${bold ? 'bold' : 'medium'} ${cls}">${val}</div>
<div class="text-xs text-gray-400">in/s</div>
</div>`;
}
function closeEventModal() {
document.getElementById('event-modal').classList.add('hidden');
}
// ── Units tab ────────────────────────────────────────────────────────────────
async function loadUnits() {
const container = document.getElementById('units-container');
try {
const r = await fetch('/api/sfm/db/units');
if (!r.ok) throw new Error('HTTP ' + r.status);
const units = await r.json();
if (units.length === 0) {
container.innerHTML = '<div class="text-center py-12 text-gray-500 text-sm">No units in database. Waiting for series3-watcher to forward events from Blastware ACH.</div>';
return;
}
const rows = units.map(u => {
const lastSeen = u.last_seen ? u.last_seen.slice(0, 19).replace('T', ' ') : '—';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td class="px-4 py-3 text-sm font-mono font-semibold text-seismo-orange">${esc(u.serial)}</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${lastSeen}</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${(u.total_events || 0).toLocaleString()}</td>
<td class="px-4 py-3 text-sm">
<button onclick="filterEventsBySerial('${esc(u.serial)}')"
class="px-3 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
View Events
</button>
</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Serial</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Last Seen</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Events</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>
</div>`;
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Failed to load units: ${e.message}</div>`;
}
}
function filterEventsBySerial(serial) {
document.getElementById('ev-serial').value = serial;
switchTab('events');
loadEvents();
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
checkSFMHealth();
loadEvents();
});
</script>
{% endblock %}