2905a327be
/admin/events previously rendered events as a flat table with no
detail view — admins had to copy an event ID and open the standalone
SFM webapp on port 8200 to see the chart, PDF, or sidecar metadata.
Adds:
- {% include 'partials/event_detail_modal.html' %} + script tag at
the bottom of the page (mirrors the pattern in /sfm, /unit/{id},
/projects/.../nrl/...).
- onclick on the table <tr> opens the modal via showEventDetail(id).
- event.stopPropagation() on the checkbox <td> so selection clicks
don't also open the modal.
- Listener for the 'sfm-event-review-saved' CustomEvent fired by
event-modal.js — reloads the table so any FT-flag changes made in
the modal's review form land on the row without a full reload.
Also propagates the same listener pattern to the three other pages
that already include the modal (sfm.html, unit_detail.html,
vibration_location_detail.html) — they call their respective
loadEvents / loadUnitEvents / loadLocationEvents on the fire. Keeps
the refresh-on-save UX consistent across every page that hosts the
modal.
Phase 1 of the SFM-into-Terra-View integration is now complete:
chart, PDF preview, .TXT download, review form, and per-unit + admin
event browsing are all native in Terra-View. The standalone SFM
webapp on port 8200 remains as a diagnostic fallback but operators
no longer need to bounce to it for routine workflows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
470 lines
22 KiB
HTML
470 lines
22 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">Events</h1>
|
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet-wide event database. Filter by serial, date, false-trigger, or browse the units roster.</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 -->
|
|
|
|
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
|
{% include 'partials/event_detail_modal.html' %}
|
|
<script src="/static/event-modal.js"></script>
|
|
<script>
|
|
// Refresh the events table when the modal's review form saves —
|
|
// keeps the FT badge in sync without a full page reload.
|
|
window.addEventListener('sfm-event-review-saved', () => {
|
|
if (typeof loadEvents === 'function') loadEvents();
|
|
});
|
|
</script>
|
|
|
|
<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 ───────────────────────────────────────────────────────────────
|
|
// Module-level cache so sort can re-render without re-fetching.
|
|
let _eventsCache = [];
|
|
let _eventsTotal = 0;
|
|
let _eventsSortKey = 'timestamp';
|
|
let _eventsSortDir = 'desc'; // 'asc' | 'desc'
|
|
|
|
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();
|
|
_eventsCache = d.events || [];
|
|
_eventsTotal = d.count || 0;
|
|
renderEventsTable(_eventsCache, _eventsTotal, container);
|
|
} catch (e) {
|
|
container.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load events: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function sortEvents(key) {
|
|
// Toggle direction if same column clicked; otherwise default to desc.
|
|
if (_eventsSortKey === key) {
|
|
_eventsSortDir = _eventsSortDir === 'desc' ? 'asc' : 'desc';
|
|
} else {
|
|
_eventsSortKey = key;
|
|
_eventsSortDir = 'desc';
|
|
}
|
|
renderEventsTable(_eventsCache, _eventsTotal, document.getElementById('events-container'));
|
|
}
|
|
|
|
function _applySort(events) {
|
|
const key = _eventsSortKey;
|
|
const dir = _eventsSortDir === 'asc' ? 1 : -1;
|
|
return [...events].sort((a, b) => {
|
|
let av = a[key], bv = b[key];
|
|
// Nulls always sort last regardless of dir.
|
|
if (av == null && bv == null) return 0;
|
|
if (av == null) return 1;
|
|
if (bv == null) return -1;
|
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
|
return String(av).localeCompare(String(bv)) * dir;
|
|
});
|
|
}
|
|
|
|
function _sortIndicator(key) {
|
|
if (_eventsSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
|
|
return _eventsSortDir === 'desc'
|
|
? '<span class="text-seismo-orange ml-1">↓</span>'
|
|
: '<span class="text-seismo-orange ml-1">↑</span>';
|
|
}
|
|
|
|
function _sortableTh(label, key) {
|
|
return `<th onclick="sortEvents('${key}')"
|
|
class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
|
${label}${_sortIndicator(key)}
|
|
</th>`;
|
|
}
|
|
|
|
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 sorted = _applySort(events);
|
|
const rows = sorted.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('${esc(ev.id)}')">
|
|
<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>
|
|
${_sortableTh('Timestamp', 'timestamp')}
|
|
${_sortableTh('Serial', 'serial')}
|
|
${_sortableTh('Project', 'project')}
|
|
${_sortableTh('Tran', 'tran_ppv')}
|
|
${_sortableTh('Vert', 'vert_ppv')}
|
|
${_sortableTh('Long', 'long_ppv')}
|
|
${_sortableTh('PVS', 'peak_vector_sum')}
|
|
${_sortableTh('Mic', 'mic_ppv')}
|
|
${_sortableTh('Flags', 'false_trigger')}
|
|
</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 now lives in /static/event-modal.js (shared component).
|
|
// `showEventDetail(eventId)` is exposed globally; row onclick handlers call it.
|
|
|
|
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
checkSFMHealth();
|
|
loadEvents();
|
|
});
|
|
</script>
|
|
{% endblock %}
|