e15481884a
Two related operator-facing improvements after the nav reorg. 1) Events as a top-level sidebar entry. The /sfm page (fleet-wide event database) was demoted to Settings → Developer in the previous reorg. Bringing it back to main nav as "Events" — operators do reach for the cross-project, sortable event list, so it earns a top-level slot. Sidebar now (7 items): Dashboard · Devices · Projects · Events · Tools · Job Planner · Settings Settings → Developer card pointing at /sfm is removed. /sfm page title/subtitle updated from "SFM Event Data" to just "Events". URL unchanged. 2) "Peak PVS" KPI tile becomes "Overall Peak" and excludes false triggers from the calculation. When operators ask "what's the biggest event at this location/unit/ project?" they mean the biggest REAL event, not the biggest sensor glitch. A single mis-flagged false trigger could otherwise dominate the tile (the 14.13 in/s spike at Loc 1 was a prime example). backend/services/sfm_events.py: - _compute_stats() skips false_trigger=True events when computing peak_pvs / peak_pvs_at / peak_pvs_serial. Continues counting them in false_trigger_count so the separate "False Triggers" tile still reflects what got filtered out. last_event unchanged (recency, not magnitude). - Same change automatically propagates to events_for_unit() and vibration_summary_for_project() — both call _compute_stats(). Templates: "Peak PVS" → "Overall Peak" in 3 KPI tile locations (vibration_location_detail.html, partials/projects/vibration_summary .html, unit_detail.html). The physical-quantity name "Peak Vector Sum" in the event-detail modal stays — that's the actual physics term, not a summary stat. Verified end-to-end: Overall Peak renders on real data; peak event false_trigger flag confirmed False. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
415 lines
21 KiB
HTML
415 lines
21 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>
|
|
|
|
<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('${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>
|
|
<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 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 %}
|