Files
terra-view/templates/sfm.html
T
claude 2ba20c7809 feat(sfm): add SFM proxy router and event data page
- backend/routers/sfm.py: HTTP proxy to SFM backend (localhost:8200),
  mirrors the SLMM proxy pattern. SFM_BASE_URL env var for docker-compose.
  Catch-all /{path} forwards to SFM root (no /api/ prefix). 60s timeout.

- templates/sfm.html: full SFM dashboard with 5 tabs:
  Events (DB listing, filters by serial/date/false-trigger, flag/unflag FT),
  Units (known serials + stats, filter events by unit),
  Monitor Log (continuous monitoring intervals),
  ACH Sessions (call-home history),
  Live Device (TCP connect, device info cards, start/stop monitoring,
  push project config, download events from device, operation log).

- backend/main.py: import sfm router, include router, add GET /sfm route
- templates/base.html: add SFM Live Data nav link under Seismographs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:14:36 -04:00

1118 lines
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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">Live device control and ACH event database</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-2 md:grid-cols-4 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 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">Monitor Intervals</span>
<span id="stat-monitor" 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">ACH Sessions</span>
<span id="stat-sessions" 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>
<button onclick="switchTab('monitor')" id="tab-monitor"
class="sfm-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
Monitor Log
</button>
<button onclick="switchTab('sessions')" id="tab-sessions"
class="sfm-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
ACH Sessions
</button>
<button onclick="switchTab('live')" id="tab-live"
class="sfm-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
Live Device
</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>
<!-- ── Monitor Log Tab ───────────────────────────────────────────────── -->
<div id="panel-monitor" class="sfm-panel hidden p-6">
<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="ml-serial" onchange="loadMonitorLog()"
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>
<button onclick="loadMonitorLog()"
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-orange-600">
↻ Reload
</button>
</div>
<div id="monitor-container" class="overflow-x-auto">
<div class="text-center py-12 text-gray-500">Loading…</div>
</div>
</div>
<!-- ── Sessions Tab ──────────────────────────────────────────────────── -->
<div id="panel-sessions" class="sfm-panel hidden p-6">
<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="sess-serial" onchange="loadSessions()"
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>
<button onclick="loadSessions()"
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-orange-600">
↻ Reload
</button>
</div>
<div id="sessions-container" class="overflow-x-auto">
<div class="text-center py-12 text-gray-500">Loading…</div>
</div>
</div>
<!-- ── Live Device Tab ───────────────────────────────────────────────── -->
<div id="panel-live" class="sfm-panel hidden p-6">
<!-- Connection form -->
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4 mb-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Connect to Device</h3>
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Host / IP</label>
<input type="text" id="live-host" placeholder="e.g. 10.0.0.1"
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 w-40">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">TCP Port</label>
<input type="number" id="live-port" placeholder="12345" value="12345"
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 w-28">
</div>
<button onclick="connectDevice()"
class="px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-orange-600 font-medium">
Connect
</button>
<button onclick="disconnectDevice()" id="btn-disconnect"
class="hidden px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
Disconnect
</button>
</div>
</div>
<!-- Device panel (hidden until connected) -->
<div id="live-device-panel" class="hidden">
<!-- Header row: serial + status + controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white" id="live-serial"></h2>
<p class="text-sm text-gray-500 dark:text-gray-400" id="live-firmware"></p>
</div>
<div class="flex items-center gap-3">
<span id="live-monitor-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-300">
</span>
<button id="btn-start-monitor" onclick="startMonitoring()"
class="hidden px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
Start Monitoring
</button>
<button id="btn-stop-monitor" onclick="stopMonitoring()"
class="hidden px-4 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium">
Stop Monitoring
</button>
<button onclick="refreshDeviceStatus()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Status
</button>
</div>
</div>
<!-- Info cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Device info card -->
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Device Info</h4>
<dl class="space-y-1.5 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Serial</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-serial"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Firmware</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-firmware"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Calibration</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-cal"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Sample Rate</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-samplerate"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Record Time</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-recordtime"></dd>
</div>
</dl>
</div>
<!-- Monitor status card -->
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Status</h4>
<dl class="space-y-1.5 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Mode</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="stat-mode"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Battery</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="stat-battery"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Memory Free</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="stat-mem-free"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Memory Total</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="stat-mem-total"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Stored Events</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="stat-event-count"></dd>
</div>
</dl>
</div>
<!-- Compliance config card -->
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Compliance Config</h4>
<dl class="space-y-1.5 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Trigger (Geo)</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-trigger"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Alarm (Geo)</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-alarm"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Max Range</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-maxrange"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Project</dt>
<dd class="font-medium text-gray-900 dark:text-white truncate max-w-[120px]" id="info-project"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Operator</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-operator"></dd>
</div>
</dl>
</div>
</div>
<!-- Config editor -->
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Update Project Info</h4>
<span id="config-saved-msg" class="hidden text-xs text-green-600 dark:text-green-400">✓ Saved</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-3">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Project</label>
<input type="text" id="cfg-project" placeholder="Project name"
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">Client</label>
<input type="text" id="cfg-client" placeholder="Client name"
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">Operator</label>
<input type="text" id="cfg-operator" placeholder="Operator name"
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">Sensor Location</label>
<input type="text" id="cfg-location" placeholder="Sensor location"
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">Trigger Level (in/s)</label>
<input type="number" id="cfg-trigger" placeholder="0.500" step="0.001" min="0"
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">Sample Rate</label>
<select id="cfg-samplerate"
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="1024">1024 sps (Standard)</option>
<option value="2048">2048 sps (Fast)</option>
<option value="4096">4096 sps (Faster)</option>
</select>
</div>
</div>
<button onclick="pushConfig()"
class="px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-orange-600 font-medium">
Push Config to Device
</button>
<span id="config-error-msg" class="hidden ml-3 text-xs text-red-500"></span>
</div>
<!-- Live events from device -->
<div>
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Events on Device</h4>
<button onclick="loadLiveEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Load Events
</button>
</div>
<div id="live-events-container">
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Load Events" to download events from the device. This may take 3060 seconds.</p>
</div>
</div>
</div>
<!-- Live operation log -->
<div id="live-log" class="mt-4 hidden">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Operation Log</h4>
<div id="live-log-content"
class="bg-gray-900 text-green-400 text-xs font-mono rounded-lg p-3 max-h-40 overflow-y-auto space-y-0.5">
</div>
</div>
</div><!-- end live panel -->
</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 _liveHost = null;
let _livePort = null;
let _liveConnected = false;
let _statusPollTimer = null;
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();
if (name === 'monitor' && document.getElementById('monitor-container').innerHTML.includes('Loading')) loadMonitorLog();
if (name === 'sessions' && document.getElementById('sessions-container').innerHTML.includes('Loading')) loadSessions();
}
// ── 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);
const totalMonitor = units.reduce((s, u) => s + (u.total_monitor_entries || 0), 0);
const totalSessions = units.reduce((s, u) => s + (u.total_sessions || 0), 0);
document.getElementById('stat-units').textContent = units.length;
document.getElementById('stat-events').textContent = totalEvents.toLocaleString();
document.getElementById('stat-monitor').textContent = totalMonitor.toLocaleString();
document.getElementById('stat-sessions').textContent = totalSessions.toLocaleString();
// Populate serial dropdowns
['ev-serial', 'ml-serial', 'sess-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. Start the ACH server and wait for a call-home.</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 text-gray-700 dark:text-gray-300">${(u.total_monitor_entries || 0).toLocaleString()}</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${(u.total_sessions || 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">Monitor Intervals</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Sessions</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();
}
// ── Monitor log tab ──────────────────────────────────────────────────────────
async function loadMonitorLog() {
const container = document.getElementById('monitor-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…</div>';
const params = new URLSearchParams();
const serial = document.getElementById('ml-serial').value;
if (serial) params.set('serial', serial);
params.set('limit', '500');
try {
const r = await fetch('/api/sfm/db/monitor_log?' + params.toString());
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
if (!d.entries || d.entries.length === 0) {
container.innerHTML = '<div class="text-center py-12 text-gray-500 text-sm">No monitor log entries found.</div>';
return;
}
const rows = d.entries.map(e => {
const start = e.start_time ? e.start_time.slice(0, 19).replace('T', ' ') : '—';
const stop = e.stop_time ? e.stop_time.slice(0, 19).replace('T', ' ') : '—';
const dur = e.duration_seconds != null ? fmtDuration(e.duration_seconds) : '—';
const thr = e.geo_threshold_ips != null ? e.geo_threshold_ips.toFixed(3) + ' in/s' : '—';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td class="px-4 py-2.5 text-sm font-mono font-medium text-seismo-orange">${esc(e.serial)}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${start}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${stop}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">${dur}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">${thr}</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">${d.entries.length} entries</div>
<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">Start</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Stop</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Duration</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Geo Threshold</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Failed to load monitor log: ${e.message}</div>`;
}
}
// ── Sessions tab ─────────────────────────────────────────────────────────────
async function loadSessions() {
const container = document.getElementById('sessions-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…</div>';
const params = new URLSearchParams();
const serial = document.getElementById('sess-serial').value;
if (serial) params.set('serial', serial);
params.set('limit', '100');
try {
const r = await fetch('/api/sfm/db/sessions?' + params.toString());
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
if (!d.sessions || d.sessions.length === 0) {
container.innerHTML = '<div class="text-center py-12 text-gray-500 text-sm">No sessions found.</div>';
return;
}
const rows = d.sessions.map(s => {
const ts = s.session_time ? s.session_time.slice(0, 19).replace('T', ' ') : '—';
const dur = s.duration_seconds != null ? fmtDuration(s.duration_seconds) : '—';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td class="px-4 py-2.5 text-sm font-mono font-medium text-seismo-orange">${esc(s.serial)}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${ts}</td>
<td class="px-4 py-2.5 text-sm text-gray-500 dark:text-gray-400">${esc(s.peer || '—')}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">${s.events_downloaded || 0}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">${s.monitor_entries || 0}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">${dur}</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">${d.sessions.length} sessions</div>
<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">Time</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Peer IP</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Events DL</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Monitor</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Failed to load sessions: ${e.message}</div>`;
}
}
// ── Live Device tab ──────────────────────────────────────────────────────────
function liveLog(msg) {
const logDiv = document.getElementById('live-log');
const content = document.getElementById('live-log-content');
logDiv.classList.remove('hidden');
const ts = new Date().toLocaleTimeString();
const line = document.createElement('div');
line.textContent = `[${ts}] ${msg}`;
content.appendChild(line);
content.scrollTop = content.scrollHeight;
}
async function connectDevice() {
const host = document.getElementById('live-host').value.trim();
const port = parseInt(document.getElementById('live-port').value) || 12345;
if (!host) {
alert('Enter a host/IP address.');
return;
}
_liveHost = host;
_livePort = port;
liveLog(`Connecting to ${host}:${port}`);
// Fetch device info
try {
const r = await fetch(`/api/sfm/device/info?host=${encodeURIComponent(host)}&tcp_port=${port}`);
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
const d = await r.json();
liveLog(`Connected — Serial: ${d.serial}, Firmware: ${d.firmware_version}`);
_liveConnected = true;
populateDeviceInfo(d);
document.getElementById('live-device-panel').classList.remove('hidden');
document.getElementById('btn-disconnect').classList.remove('hidden');
// Pre-fill config form
if (d.compliance_config) {
const cc = d.compliance_config;
if (cc.project) document.getElementById('cfg-project').value = cc.project;
if (cc.client) document.getElementById('cfg-client').value = cc.client;
if (cc.operator) document.getElementById('cfg-operator').value = cc.operator;
if (cc.sensor_location) document.getElementById('cfg-location').value = cc.sensor_location;
if (cc.trigger_level_geo) document.getElementById('cfg-trigger').value = cc.trigger_level_geo.toFixed(3);
if (cc.sample_rate) document.getElementById('cfg-samplerate').value = cc.sample_rate;
}
// Fetch monitor status
await refreshDeviceStatus();
// Start polling every 15s
if (_statusPollTimer) clearInterval(_statusPollTimer);
_statusPollTimer = setInterval(refreshDeviceStatus, 15000);
} catch (e) {
liveLog(`Connection failed: ${e.message}`);
_liveConnected = false;
}
}
function disconnectDevice() {
_liveConnected = false;
_liveHost = null;
_livePort = null;
if (_statusPollTimer) { clearInterval(_statusPollTimer); _statusPollTimer = null; }
document.getElementById('live-device-panel').classList.add('hidden');
document.getElementById('btn-disconnect').classList.add('hidden');
liveLog('Disconnected.');
}
function populateDeviceInfo(d) {
document.getElementById('live-serial').textContent = d.serial || '—';
document.getElementById('live-firmware').textContent = d.firmware_version || '—';
document.getElementById('info-serial').textContent = d.serial || '—';
document.getElementById('info-firmware').textContent = d.firmware_version || '—';
document.getElementById('info-cal').textContent = d.calibration_date || '—';
document.getElementById('stat-event-count').textContent = (d.event_count != null) ? d.event_count : '—';
const cc = d.compliance_config;
if (cc) {
document.getElementById('info-samplerate').textContent = (cc.sample_rate || '—') + ' sps';
document.getElementById('info-recordtime').textContent = cc.record_time != null ? cc.record_time.toFixed(1) + ' s' : '—';
document.getElementById('info-trigger').textContent = cc.trigger_level_geo != null ? cc.trigger_level_geo.toFixed(3) + ' in/s' : '—';
document.getElementById('info-alarm').textContent = cc.alarm_level_geo != null ? cc.alarm_level_geo.toFixed(3) + ' in/s' : '—';
document.getElementById('info-maxrange').textContent = cc.max_range_geo != null ? cc.max_range_geo.toFixed(2) + ' in/s' : '—';
document.getElementById('info-project').textContent = cc.project || '—';
document.getElementById('info-operator').textContent = cc.operator || '—';
}
}
async function refreshDeviceStatus() {
if (!_liveHost || !_livePort) return;
try {
const r = await fetch(`/api/sfm/device/monitor/status?host=${encodeURIComponent(_liveHost)}&tcp_port=${_livePort}`);
if (!r.ok) {
liveLog('Status poll failed: HTTP ' + r.status);
return;
}
const d = await r.json();
const isMonitoring = d.is_monitoring;
const badge = document.getElementById('live-monitor-badge');
const btnStart = document.getElementById('btn-start-monitor');
const btnStop = document.getElementById('btn-stop-monitor');
if (isMonitoring) {
badge.textContent = '● Monitoring';
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';
btnStart.classList.add('hidden');
btnStop.classList.remove('hidden');
} else {
badge.textContent = '○ Idle';
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-300';
btnStart.classList.remove('hidden');
btnStop.classList.add('hidden');
}
document.getElementById('stat-mode').textContent = isMonitoring ? 'Monitoring' : 'Idle';
if (d.battery_voltage_v != null) {
document.getElementById('stat-battery').textContent = d.battery_voltage_v.toFixed(2) + ' V';
}
if (d.memory_free_bytes != null && d.memory_total_bytes != null) {
document.getElementById('stat-mem-free').textContent = fmtBytes(d.memory_free_bytes);
document.getElementById('stat-mem-total').textContent = fmtBytes(d.memory_total_bytes);
}
} catch (e) {
liveLog(`Status error: ${e.message}`);
}
}
async function startMonitoring() {
if (!_liveHost) return;
liveLog('Starting monitoring…');
const btn = document.getElementById('btn-start-monitor');
btn.disabled = true; btn.textContent = 'Starting…';
try {
const r = await fetch(`/api/sfm/device/monitor/start?host=${encodeURIComponent(_liveHost)}&tcp_port=${_livePort}`, { method: 'POST' });
const d = await r.json();
if (r.ok) {
liveLog('Monitoring started.');
setTimeout(refreshDeviceStatus, 2000);
} else {
liveLog('Start failed: ' + (d.detail || r.status));
}
} catch (e) {
liveLog('Start error: ' + e.message);
}
btn.disabled = false; btn.textContent = 'Start Monitoring';
}
async function stopMonitoring() {
if (!_liveHost) return;
liveLog('Stopping monitoring…');
const btn = document.getElementById('btn-stop-monitor');
btn.disabled = true; btn.textContent = 'Stopping…';
try {
const r = await fetch(`/api/sfm/device/monitor/stop?host=${encodeURIComponent(_liveHost)}&tcp_port=${_livePort}`, { method: 'POST' });
const d = await r.json();
if (r.ok) {
liveLog('Monitoring stopped.');
setTimeout(refreshDeviceStatus, 2000);
} else {
liveLog('Stop failed: ' + (d.detail || r.status));
}
} catch (e) {
liveLog('Stop error: ' + e.message);
}
btn.disabled = false; btn.textContent = 'Stop Monitoring';
}
async function pushConfig() {
if (!_liveHost) return;
const errEl = document.getElementById('config-error-msg');
const savedEl = document.getElementById('config-saved-msg');
errEl.classList.add('hidden');
savedEl.classList.add('hidden');
const body = {
project: document.getElementById('cfg-project').value.trim() || null,
client_name: document.getElementById('cfg-client').value.trim() || null,
operator: document.getElementById('cfg-operator').value.trim() || null,
sensor_location: document.getElementById('cfg-location').value.trim() || null,
trigger_level_geo: parseFloat(document.getElementById('cfg-trigger').value) || null,
sample_rate: parseInt(document.getElementById('cfg-samplerate').value) || null,
};
liveLog('Pushing config to device…');
try {
const r = await fetch(
`/api/sfm/device/config/project?host=${encodeURIComponent(_liveHost)}&tcp_port=${_livePort}`,
{ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }
);
const d = await r.json();
if (r.ok) {
liveLog('Config pushed successfully.');
savedEl.classList.remove('hidden');
setTimeout(() => savedEl.classList.add('hidden'), 3000);
} else {
const msg = d.detail || ('HTTP ' + r.status);
liveLog('Config push failed: ' + msg);
errEl.textContent = msg;
errEl.classList.remove('hidden');
}
} catch (e) {
liveLog('Config error: ' + e.message);
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
async function loadLiveEvents() {
if (!_liveHost) return;
const container = document.getElementById('live-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><p class="text-sm">Downloading events from device…<br><span class="text-xs">This may take 3060 seconds.</span></p></div>';
liveLog('Downloading events from device…');
try {
const r = await fetch(`/api/sfm/device/events?host=${encodeURIComponent(_liveHost)}&tcp_port=${_livePort}`, {signal: AbortSignal.timeout(120000)});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
const d = await r.json();
liveLog(`Downloaded ${d.event_count} events.`);
if (d.cached) liveLog('(served from cache — no new events detected)');
renderEventsTable(d.events, d.event_count, container);
} catch (e) {
liveLog('Event download failed: ' + e.message);
container.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Failed: ${e.message}</div>`;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDuration(secs) {
if (secs == null) return '—';
if (secs < 60) return secs.toFixed(0) + 's';
const m = Math.floor(secs / 60), s = Math.floor(secs % 60);
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60), rm = m % 60;
return `${h}h ${rm}m`;
}
function fmtBytes(b) {
if (b == null) return '—';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(0) + ' KB';
return (b / 1048576).toFixed(1) + ' MB';
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
checkSFMHealth();
loadEvents();
});
</script>
{% endblock %}