Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 904ff04440 | |||
| 155f0b007a |
@@ -106,6 +106,9 @@ app.include_router(settings.router)
|
||||
from backend.routers import watcher_manager
|
||||
app.include_router(watcher_manager.router)
|
||||
|
||||
from backend.routers import admin_modules
|
||||
app.include_router(admin_modules.router)
|
||||
|
||||
# Projects system routers
|
||||
app.include_router(projects.router)
|
||||
app.include_router(project_locations.router)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Admin / diagnostic pages for the device modules (SFM, SLMM).
|
||||
|
||||
These pages live under /admin/{module} and exist purely so an operator can
|
||||
peek under the hood and confirm the module is reachable, what data it's
|
||||
holding, and whether the proxy from terra-view is healthy.
|
||||
|
||||
Routes:
|
||||
GET /admin/sfm — SFM diagnostic page
|
||||
GET /admin/slmm — SLMM diagnostic page
|
||||
|
||||
API helpers (called by the HTML pages via fetch):
|
||||
GET /api/admin/sfm/overview — aggregated SFM health + db stats in one call
|
||||
GET /api/admin/slmm/overview — aggregated SLMM health + device count
|
||||
|
||||
The pages are intentionally read-only. Any actual administration of SFM
|
||||
or SLMM happens in those modules directly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.templates_config import templates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
# ── SFM ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/admin/sfm", response_class=HTMLResponse)
|
||||
def admin_sfm_page(request: Request):
|
||||
return templates.TemplateResponse("admin_sfm.html", {
|
||||
"request": request,
|
||||
"sfm_base_url": SFM_BASE_URL,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/admin/sfm/overview")
|
||||
async def admin_sfm_overview() -> JSONResponse:
|
||||
"""Aggregated SFM diagnostic snapshot.
|
||||
|
||||
Returns health, db stats, stale-table counts, per-unit summary, and
|
||||
recent events with forwarding latency. Tolerant of partial failures:
|
||||
any individual sub-fetch error is captured into its section, so a flaky
|
||||
sub-endpoint doesn't break the whole page.
|
||||
"""
|
||||
overview: Dict[str, Any] = {
|
||||
"sfm_base_url": SFM_BASE_URL,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"health": None,
|
||||
"reachable": False,
|
||||
"units": [],
|
||||
"events": [],
|
||||
"stale": {
|
||||
"monitor_log": None,
|
||||
"sessions": None,
|
||||
},
|
||||
"cache_stats": None,
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Health
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/health")
|
||||
r.raise_for_status()
|
||||
overview["health"] = r.json()
|
||||
overview["reachable"] = overview["health"].get("status") == "ok"
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["health"] = str(e)
|
||||
overview["reachable"] = False
|
||||
|
||||
# If SFM is down, no point hitting the rest.
|
||||
if not overview["reachable"]:
|
||||
return JSONResponse(overview)
|
||||
|
||||
# Units
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/db/units")
|
||||
r.raise_for_status()
|
||||
overview["units"] = r.json() or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["units"] = str(e)
|
||||
|
||||
# Recent events (newest 25 — bigger sample of the call-home stream)
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/db/events", params={"limit": 25})
|
||||
r.raise_for_status()
|
||||
payload = r.json() or {}
|
||||
events = payload.get("events", []) or []
|
||||
# Compute forwarding latency: created_at (SFM ingest) − timestamp (event).
|
||||
now = datetime.now(timezone.utc)
|
||||
for ev in events:
|
||||
ev.pop("waveform_blob", None)
|
||||
ev.pop("a5_pickle_filename", None)
|
||||
ts_str = ev.get("timestamp")
|
||||
ca_str = ev.get("created_at")
|
||||
latency_seconds = None
|
||||
try:
|
||||
if ts_str and ca_str:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
ca = datetime.fromisoformat(ca_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc)
|
||||
if ca.tzinfo is None: ca = ca.replace(tzinfo=timezone.utc)
|
||||
latency_seconds = (ca - ts).total_seconds()
|
||||
except ValueError:
|
||||
pass
|
||||
ev["forwarding_latency_seconds"] = latency_seconds
|
||||
overview["events"] = events
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["events"] = str(e)
|
||||
|
||||
# Stale tables (deprecated by the watcher-forward pipeline but still
|
||||
# present in SFM's SQLite). Surface as counts only.
|
||||
for key, path in (("monitor_log", "/db/monitor_log"),
|
||||
("sessions", "/db/sessions")):
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}{path}", params={"limit": 1})
|
||||
r.raise_for_status()
|
||||
payload = r.json() or {}
|
||||
# SFM returns count = total when limit covers all rows; we
|
||||
# query with limit=1 just to be polite, then ask again with
|
||||
# a high limit if we need the real total.
|
||||
first_count = payload.get("count")
|
||||
if first_count is None:
|
||||
overview["stale"][key] = None
|
||||
continue
|
||||
# Re-query with high limit to get the true total.
|
||||
r2 = await client.get(f"{SFM_BASE_URL}{path}", params={"limit": 100000})
|
||||
r2.raise_for_status()
|
||||
overview["stale"][key] = (r2.json() or {}).get("count")
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"][f"stale_{key}"] = str(e)
|
||||
|
||||
# Cache stats (in-memory device cache on SFM)
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/cache/stats")
|
||||
r.raise_for_status()
|
||||
overview["cache_stats"] = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["cache_stats"] = str(e)
|
||||
|
||||
# Aggregate counts the UI can render without re-walking arrays
|
||||
overview["totals"] = {
|
||||
"units": len(overview["units"]),
|
||||
"events_total": sum(u.get("total_events", 0) for u in overview["units"]),
|
||||
"stale_monitor_log": overview["stale"]["monitor_log"],
|
||||
"stale_sessions": overview["stale"]["sessions"],
|
||||
}
|
||||
|
||||
return JSONResponse(overview)
|
||||
|
||||
|
||||
# ── SLMM ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/admin/slmm", response_class=HTMLResponse)
|
||||
def admin_slmm_page(request: Request):
|
||||
return templates.TemplateResponse("admin_slmm.html", {
|
||||
"request": request,
|
||||
"slmm_base_url": SLMM_BASE_URL,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/admin/slmm/overview")
|
||||
async def admin_slmm_overview() -> JSONResponse:
|
||||
"""Aggregated SLMM diagnostic snapshot."""
|
||||
overview: Dict[str, Any] = {
|
||||
"slmm_base_url": SLMM_BASE_URL,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"health": None,
|
||||
"reachable": False,
|
||||
"devices": [],
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
r = await client.get(f"{SLMM_BASE_URL}/health")
|
||||
r.raise_for_status()
|
||||
overview["health"] = r.json()
|
||||
overview["reachable"] = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["health"] = str(e)
|
||||
return JSONResponse(overview)
|
||||
|
||||
# Pull a roster of configured devices (SLMM exposes per-unit
|
||||
# config + status under /api/nl43/*). This is a best-effort probe
|
||||
# — SLMM doesn't expose a "list all devices" endpoint, so we ask
|
||||
# terra-view's RosterUnit table what serials it knows about for
|
||||
# SLMs and just check each one. For now, just surface the health
|
||||
# payload and let the operator click through to /sound-level-meters
|
||||
# for the per-device details.
|
||||
|
||||
return JSONResponse(overview)
|
||||
@@ -62,6 +62,27 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _deriveRecordType(filename, fallback) {
|
||||
// SFM currently hardcodes record_type="Waveform" for every event.
|
||||
// The actual type is encoded in the LAST character of the Blastware
|
||||
// filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram).
|
||||
// We derive it client-side until SFM is fixed; if the suffix isn't
|
||||
// a known code we fall back to whatever SFM reported.
|
||||
if (!filename) return fallback || '—';
|
||||
const dotIdx = filename.lastIndexOf('.');
|
||||
if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—';
|
||||
const ext = filename.slice(dotIdx + 1);
|
||||
const lastChar = ext.slice(-1).toUpperCase();
|
||||
const typeMap = {
|
||||
'H': 'Histogram',
|
||||
'W': 'Waveform',
|
||||
'M': 'Manual',
|
||||
'E': 'Event',
|
||||
'C': 'Combo',
|
||||
};
|
||||
return typeMap[lastChar] || (fallback || '—');
|
||||
}
|
||||
|
||||
function _sectionHeader(title, sub) {
|
||||
return `<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-5 first:mt-0">
|
||||
${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
|
||||
@@ -72,21 +93,23 @@
|
||||
|
||||
function _renderEventHeader(s) {
|
||||
const ev = s.event || {};
|
||||
const bw = s.blastware || {};
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type);
|
||||
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||
<div><span class="text-gray-500">Serial</span> <span class="font-mono font-semibold text-seismo-orange ml-1">${_esc(ev.serial)}</span></div>
|
||||
<div><span class="text-gray-500">Timestamp</span> <span class="font-medium ml-1">${ts}</span></div>
|
||||
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(ev.record_type || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(recType)}</span></div>
|
||||
<div><span class="text-gray-500">Sample Rate</span> <span class="font-medium ml-1">${ev.sample_rate ?? '—'} sps</span></div>
|
||||
<div><span class="text-gray-500">Rec Time</span> <span class="font-medium ml-1">${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}</span></div>
|
||||
<div><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '—')}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderProjectInfo(s) {
|
||||
function _renderUserNotes(s) {
|
||||
// The "user notes" metadata the operator typed into the BW device.
|
||||
// These are the strings the future metadata-driven parser will use.
|
||||
const p = s.project_info || {};
|
||||
const p = s.user_notes || {};
|
||||
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
|
||||
@@ -120,20 +143,21 @@
|
||||
}
|
||||
|
||||
function _renderMic(s) {
|
||||
// Operators only care about dB(L); PSI tile was dropped 2026-05.
|
||||
// We still render the row if any mic data is present so ZC freq /
|
||||
// time-of-peak stay visible even when bw_report.mic is missing.
|
||||
const mic = (s.bw_report && s.bw_report.mic) || null;
|
||||
const pv = s.peak_values || {};
|
||||
|
||||
if (!mic && pv.mic_psi == null) return '';
|
||||
|
||||
const dbl = mic?.pspl_dbl;
|
||||
const psi = pv.mic_psi;
|
||||
const zcHz = mic?.zc_freq_hz;
|
||||
const tPk = mic?.time_of_peak_s;
|
||||
const wt = mic?.weighting;
|
||||
|
||||
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
|
||||
${_kvCard('Peak Mic psi', _fmt(psi, 4))}
|
||||
${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
|
||||
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
|
||||
</div>`;
|
||||
@@ -223,6 +247,14 @@
|
||||
Blastware file unavailable
|
||||
</span>
|
||||
`}
|
||||
<button type="button"
|
||||
onclick="window.toggleEventJsonViewer()"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
<span id="event-json-toggle-label">View JSON</span>
|
||||
</button>
|
||||
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar"
|
||||
download="${_esc((bw.filename || 'event') + '.sfm.json')}"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||
@@ -232,6 +264,16 @@
|
||||
Download sidecar JSON
|
||||
</a>
|
||||
</div>
|
||||
<div id="event-json-viewer" class="hidden mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
||||
<button type="button" onclick="window.copyEventJson()"
|
||||
class="text-xs text-seismo-orange hover:text-seismo-navy">
|
||||
<span id="event-json-copy-label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre id="event-json-pre" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-4 rounded-lg max-h-96 overflow-auto whitespace-pre">${_esc(JSON.stringify(s, null, 2))}</pre>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `${downloadButtons}
|
||||
@@ -294,8 +336,8 @@
|
||||
${_sectionHeader('Event')}
|
||||
${_renderEventHeader(s)}
|
||||
|
||||
${_sectionHeader('Project Info', '(operator-typed at session start)')}
|
||||
${_renderProjectInfo(s)}
|
||||
${_sectionHeader('User Notes')}
|
||||
${_renderUserNotes(s)}
|
||||
|
||||
${_sectionHeader('Peak Particle Velocity')}
|
||||
${_renderPeakValues(s)}
|
||||
@@ -323,6 +365,32 @@
|
||||
if (modal) modal.classList.add('hidden');
|
||||
};
|
||||
|
||||
window.toggleEventJsonViewer = function () {
|
||||
const viewer = document.getElementById('event-json-viewer');
|
||||
const label = document.getElementById('event-json-toggle-label');
|
||||
if (!viewer) return;
|
||||
const isHidden = viewer.classList.toggle('hidden');
|
||||
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
||||
};
|
||||
|
||||
window.copyEventJson = function () {
|
||||
const pre = document.getElementById('event-json-pre');
|
||||
const label = document.getElementById('event-json-copy-label');
|
||||
if (!pre) return;
|
||||
navigator.clipboard.writeText(pre.textContent).then(() => {
|
||||
if (label) {
|
||||
label.textContent = 'Copied!';
|
||||
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('clipboard write failed', err);
|
||||
if (label) {
|
||||
label.textContent = 'Failed';
|
||||
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Close on Escape.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') window.closeEventDetailModal();
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}SFM Admin - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/settings#developer" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Developer Tools</a>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">SFM Admin</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Diagnostics for the Seismograph Field Module (SFM) backend.</p>
|
||||
</div>
|
||||
<button onclick="loadSfmOverview()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
|
||||
<span id="refresh-label">↻ Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Health Banner -->
|
||||
<div id="health-banner" class="rounded-xl p-4 mb-6 bg-gray-100 dark:bg-slate-800 border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading SFM status…</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection Info -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-2">Connection</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">terra-view → SFM URL</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5">{{ sfm_base_url }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Last checked</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="checked-at">—</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Version</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="sfm-version">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Known Units</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white mt-1" id="stat-units">—</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white mt-1" id="stat-events">—</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Rows in SFM's deprecated monitor_log table (from paused Python-ACH experiment)">Stale: monitor_log</div>
|
||||
<div class="text-2xl font-bold text-gray-400 dark:text-gray-500 mt-1" id="stat-stale-monitor">—</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Rows in SFM's deprecated ach_sessions table (from paused Python-ACH experiment)">Stale: ach_sessions</div>
|
||||
<div class="text-2xl font-bold text-gray-400 dark:text-gray-500 mt-1" id="stat-stale-sessions">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Units Table -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Per-Unit Roll-up</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">All seismograph serials SFM has ever seen, with their last-event timestamp and total event count. Sourced from <code class="font-mono">GET /db/units</code>.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Serial</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Last Seen</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase text-right">Events</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase text-right">Monitor (stale)</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase text-right">Sessions (stale)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="units-tbody" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Events with Latency -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Recent Events — Forwarding Latency</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">The last 25 events SFM ingested, with the gap between the event's recorded timestamp and when SFM received the forward. Large latencies indicate the watcher is forwarding stale files (e.g. after a network outage).</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Recorded</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Serial</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Forwarded</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Latency</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">File</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-tbody" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw API tester -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Send a GET request to any SFM endpoint via the terra-view <code class="font-mono">/api/sfm/*</code> proxy. Path is relative to SFM root (no leading slash).</p>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<span class="px-3 py-2 text-sm bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 font-mono rounded-l-lg">/api/sfm/</span>
|
||||
<input id="raw-path" type="text" placeholder="db/units" value="db/units"
|
||||
class="flex-1 px-3 py-2 text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded font-mono"
|
||||
onkeydown="if(event.key==='Enter') sendRaw();">
|
||||
<button onclick="sendRaw()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg">
|
||||
GET
|
||||
</button>
|
||||
</div>
|
||||
<pre id="raw-response" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-3 rounded-lg max-h-96 overflow-auto whitespace-pre hidden"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function _fmtAge(seconds) {
|
||||
if (seconds == null) return '—';
|
||||
const abs = Math.abs(seconds);
|
||||
if (abs < 60) return seconds.toFixed(0) + 's';
|
||||
if (abs < 3600) return (seconds / 60).toFixed(1) + 'm';
|
||||
if (abs < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
||||
return (seconds / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
function _latencyClass(seconds) {
|
||||
if (seconds == null) return 'text-gray-400';
|
||||
if (seconds < 600) return 'text-green-600 dark:text-green-400'; // <10 min
|
||||
if (seconds < 3600) return 'text-amber-600 dark:text-amber-400'; // <1 hr
|
||||
return 'text-red-600 dark:text-red-400 font-semibold'; // 1hr+
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function loadSfmOverview() {
|
||||
const lbl = document.getElementById('refresh-label');
|
||||
lbl.textContent = '↻ Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/admin/sfm/overview');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
renderOverview(d);
|
||||
} catch (e) {
|
||||
document.getElementById('health-banner').innerHTML = `
|
||||
<div class="text-red-600 dark:text-red-400 text-sm font-medium">
|
||||
Failed to load SFM overview: ${_esc(e.message)}
|
||||
</div>`;
|
||||
} finally {
|
||||
lbl.textContent = '↻ Refresh';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverview(d) {
|
||||
// Banner
|
||||
const banner = document.getElementById('health-banner');
|
||||
if (d.reachable) {
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-green-800 dark:text-green-300">SFM reachable</div>
|
||||
<div class="text-xs text-green-700 dark:text-green-400">${_esc(d.health?.service || 'sfm')} v${_esc(d.health?.version || '?')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800';
|
||||
const errs = Object.entries(d.errors || {}).map(([k, v]) => `${k}: ${v}`).join('; ');
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-red-800 dark:text-red-300">SFM unreachable</div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400">${_esc(errs || 'no details')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Header info
|
||||
document.getElementById('checked-at').textContent = d.checked_at ? d.checked_at.slice(0, 19).replace('T', ' ') : '—';
|
||||
document.getElementById('sfm-version').textContent = d.health?.version || '—';
|
||||
|
||||
// Stats
|
||||
const t = d.totals || {};
|
||||
document.getElementById('stat-units').textContent = (t.units ?? 0).toLocaleString();
|
||||
document.getElementById('stat-events').textContent = (t.events_total ?? 0).toLocaleString();
|
||||
document.getElementById('stat-stale-monitor').textContent = t.stale_monitor_log != null ? t.stale_monitor_log.toLocaleString() : '—';
|
||||
document.getElementById('stat-stale-sessions').textContent = t.stale_sessions != null ? t.stale_sessions.toLocaleString() : '—';
|
||||
|
||||
// Units table
|
||||
const unitsBody = document.getElementById('units-tbody');
|
||||
if (!d.units || d.units.length === 0) {
|
||||
unitsBody.innerHTML = `<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">No units in SFM yet.</td></tr>`;
|
||||
} else {
|
||||
const sorted = [...d.units].sort((a, b) => (b.last_seen || '').localeCompare(a.last_seen || ''));
|
||||
unitsBody.innerHTML = sorted.map(u => {
|
||||
const ls = u.last_seen ? u.last_seen.replace('T', ' ').slice(0, 19) : '—';
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<td class="px-3 py-2 font-mono text-seismo-orange"><a href="/unit/${encodeURIComponent(u.serial)}" class="hover:underline">${_esc(u.serial)}</a></td>
|
||||
<td class="px-3 py-2 text-gray-900 dark:text-gray-200">${_esc(ls)}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-900 dark:text-gray-200">${(u.total_events || 0).toLocaleString()}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-500">${(u.total_monitor_entries || 0).toLocaleString()}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-500">${(u.total_sessions || 0).toLocaleString()}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Events table
|
||||
const evBody = document.getElementById('events-tbody');
|
||||
if (!d.events || d.events.length === 0) {
|
||||
evBody.innerHTML = `<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">No events.</td></tr>`;
|
||||
} else {
|
||||
evBody.innerHTML = d.events.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const ca = ev.created_at ? ev.created_at.replace('T', ' ').slice(0, 19) : '—';
|
||||
const lat = ev.forwarding_latency_seconds;
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<td class="px-3 py-2 text-gray-900 dark:text-gray-200 whitespace-nowrap">${_esc(ts)}</td>
|
||||
<td class="px-3 py-2 font-mono text-seismo-orange">${_esc(ev.serial)}</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">${_esc(ca)}</td>
|
||||
<td class="px-3 py-2 font-mono ${_latencyClass(lat)}">${_fmtAge(lat)}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">${_esc(ev.blastware_filename || '—')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRaw() {
|
||||
const path = document.getElementById('raw-path').value.trim().replace(/^\//, '');
|
||||
if (!path) return;
|
||||
const pre = document.getElementById('raw-response');
|
||||
pre.classList.remove('hidden');
|
||||
pre.textContent = 'Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/sfm/' + path);
|
||||
const text = await r.text();
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
pre.textContent = `HTTP ${r.status}\n\n${JSON.stringify(j, null, 2)}`;
|
||||
} catch {
|
||||
pre.textContent = `HTTP ${r.status}\n\n${text.slice(0, 8000)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
pre.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
loadSfmOverview();
|
||||
setInterval(loadSfmOverview, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}SLMM Admin - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/settings#developer" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Developer Tools</a>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">SLMM Admin</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Diagnostics for the Sound Level Meter Manager (SLMM) backend.</p>
|
||||
</div>
|
||||
<button onclick="loadSlmmOverview()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
|
||||
<span id="refresh-label">↻ Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Health Banner -->
|
||||
<div id="health-banner" class="rounded-xl p-4 mb-6 bg-gray-100 dark:bg-slate-800 border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading SLMM status…</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection Info -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-2">Connection</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">terra-view → SLMM URL</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5">{{ slmm_base_url }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Last checked</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="checked-at">—</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Version</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="slmm-version">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
For per-device SLM control, see the <a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-burgundy underline">Sound Level Meters dashboard</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw API tester -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Send a GET request to any SLMM endpoint via the terra-view <code class="font-mono">/api/slmm/*</code> proxy.</p>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<span class="px-3 py-2 text-sm bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 font-mono rounded-l-lg">/api/slmm/</span>
|
||||
<input id="raw-path" type="text" placeholder="health" value="health"
|
||||
class="flex-1 px-3 py-2 text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded font-mono"
|
||||
onkeydown="if(event.key==='Enter') sendRaw();">
|
||||
<button onclick="sendRaw()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg">
|
||||
GET
|
||||
</button>
|
||||
</div>
|
||||
<pre id="raw-response" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-3 rounded-lg max-h-96 overflow-auto whitespace-pre hidden"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function loadSlmmOverview() {
|
||||
const lbl = document.getElementById('refresh-label');
|
||||
lbl.textContent = '↻ Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/admin/slmm/overview');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
|
||||
document.getElementById('checked-at').textContent = d.checked_at ? d.checked_at.slice(0, 19).replace('T', ' ') : '—';
|
||||
document.getElementById('slmm-version').textContent = d.health?.version || '—';
|
||||
|
||||
const banner = document.getElementById('health-banner');
|
||||
if (d.reachable) {
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-green-800 dark:text-green-300">SLMM reachable</div>
|
||||
<div class="text-xs text-green-700 dark:text-green-400">${_esc(d.health?.service || 'slmm')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
const errs = Object.entries(d.errors || {}).map(([k, v]) => `${k}: ${v}`).join('; ');
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-red-800 dark:text-red-300">SLMM unreachable</div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400">${_esc(errs || 'no details')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('health-banner').innerHTML = `
|
||||
<div class="text-red-600 dark:text-red-400 text-sm font-medium">
|
||||
Failed to load SLMM overview: ${_esc(e.message)}
|
||||
</div>`;
|
||||
} finally {
|
||||
lbl.textContent = '↻ Refresh';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRaw() {
|
||||
const path = document.getElementById('raw-path').value.trim().replace(/^\//, '');
|
||||
if (!path) return;
|
||||
const pre = document.getElementById('raw-response');
|
||||
pre.classList.remove('hidden');
|
||||
pre.textContent = 'Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/slmm/' + path);
|
||||
const text = await r.text();
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
pre.textContent = `HTTP ${r.status}\n\n${JSON.stringify(j, null, 2)}`;
|
||||
} catch {
|
||||
pre.textContent = `HTTP ${r.status}\n\n${text.slice(0, 8000)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
pre.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
loadSlmmOverview();
|
||||
setInterval(loadSlmmOverview, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
+27
-1
@@ -561,7 +561,33 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# SFM Admin moved back to main nav as "Events" — see sidebar. #}
|
||||
<!-- SFM Admin -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900 dark:text-white">SFM Admin</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Diagnose the SFM backend — health, per-unit event counts, forwarding latency, stale tables, raw API probe.
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/sfm"
|
||||
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- SLMM Admin -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900 dark:text-white">SLMM Admin</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Diagnose the SLMM backend — health check + raw API probe. For per-device control use the SLM dashboard.
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/slmm"
|
||||
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
||||
operator workflows, not admin/dev surfaces). Find them
|
||||
|
||||
+59
-11
@@ -220,6 +220,12 @@ async function loadStats() {
|
||||
}
|
||||
|
||||
// ── 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>';
|
||||
@@ -241,19 +247,61 @@ async function loadEvents() {
|
||||
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);
|
||||
_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 rows = events.map(ev => {
|
||||
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);
|
||||
@@ -288,15 +336,15 @@ function renderEventsTable(events, total, container) {
|
||||
<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>
|
||||
${_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>
|
||||
|
||||
@@ -2142,6 +2142,15 @@ function clearUnitEventFilters() {
|
||||
loadUnitEvents();
|
||||
}
|
||||
|
||||
// Module-level state for the unit-events table sort. Cache lets us re-sort
|
||||
// without a refetch when the user clicks a column header.
|
||||
let _ueEventsCache = [];
|
||||
let _ueEventsTotal = 0;
|
||||
let _ueEventsBucket = 'all';
|
||||
let _ueAssignmentsTotal = 0;
|
||||
let _ueSortKey = 'timestamp';
|
||||
let _ueSortDir = 'desc';
|
||||
|
||||
async function loadUnitEvents() {
|
||||
if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
|
||||
const container = document.getElementById('ue-events-container');
|
||||
@@ -2166,13 +2175,62 @@ async function loadUnitEvents() {
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
const d = await r.json();
|
||||
_ueEventsCache = d.events || [];
|
||||
_ueEventsTotal = d.count || 0;
|
||||
_ueEventsBucket = bucket;
|
||||
_ueAssignmentsTotal = d.assignments_total || 0;
|
||||
renderUnitEventStats(d.stats);
|
||||
renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total);
|
||||
renderUnitEventTable(_ueEventsCache, _ueEventsTotal, container, bucket, _ueAssignmentsTotal);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function sortUnitEvents(key) {
|
||||
if (_ueSortKey === key) {
|
||||
_ueSortDir = _ueSortDir === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
_ueSortKey = key;
|
||||
_ueSortDir = 'desc';
|
||||
}
|
||||
renderUnitEventTable(_ueEventsCache, _ueEventsTotal,
|
||||
document.getElementById('ue-events-container'), _ueEventsBucket, _ueAssignmentsTotal);
|
||||
}
|
||||
|
||||
function _ueApplySort(events) {
|
||||
const key = _ueSortKey;
|
||||
const dir = _ueSortDir === 'asc' ? 1 : -1;
|
||||
return [...events].sort((a, b) => {
|
||||
let av, bv;
|
||||
if (key === 'attribution') {
|
||||
// Sort by location name so attributed rows group together.
|
||||
av = a.attribution ? (a.attribution.location_name || '') : '';
|
||||
bv = b.attribution ? (b.attribution.location_name || '') : '';
|
||||
} else {
|
||||
av = a[key]; bv = b[key];
|
||||
}
|
||||
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 _ueSortIndicator(key) {
|
||||
if (_ueSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
|
||||
return _ueSortDir === 'desc'
|
||||
? '<span class="text-seismo-orange ml-1">↓</span>'
|
||||
: '<span class="text-seismo-orange ml-1">↑</span>';
|
||||
}
|
||||
|
||||
function _ueSortableTh(label, key) {
|
||||
return `<th onclick="sortUnitEvents('${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}${_ueSortIndicator(key)}
|
||||
</th>`;
|
||||
}
|
||||
|
||||
function renderUnitEventStats(stats) {
|
||||
const s = stats || {};
|
||||
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
|
||||
@@ -2269,7 +2327,8 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = events.map(ev => {
|
||||
const sorted = _ueApplySort(events);
|
||||
const rows = sorted.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const tran = _ueFmtPPV(ev.tran_ppv);
|
||||
const vert = _ueFmtPPV(ev.vert_ppv);
|
||||
@@ -2295,13 +2354,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
||||
<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">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">Flags</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
|
||||
${_ueSortableTh('Timestamp', 'timestamp')}
|
||||
${_ueSortableTh('Tran', 'tran_ppv')}
|
||||
${_ueSortableTh('Vert', 'vert_ppv')}
|
||||
${_ueSortableTh('Long', 'long_ppv')}
|
||||
${_ueSortableTh('PVS', 'peak_vector_sum')}
|
||||
${_ueSortableTh('Flags', 'false_trigger')}
|
||||
${_ueSortableTh('Attribution', 'attribution')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
|
||||
Reference in New Issue
Block a user