904ff04440
New /admin/sfm page (linked from Settings → Developer):
- Health banner — green/red with version + last-checked timestamp
- Connection panel — shows SFM_BASE_URL terra-view is configured with
- 4 KPI tiles — known units, total events, stale monitor_log rows,
stale ach_sessions rows (the deprecated tables from the paused
Python-ACH experiment, useful for confirming nothing's growing them)
- Per-unit roll-up table — serial, last_seen, event count, stale
per-unit counts, sourced from SFM's /db/units
- Recent events with forwarding latency — color-coded gap between
the event's recorded timestamp and SFM ingest time, so operators
can spot watchers that are forwarding stale files (e.g. after a
jobsite outage)
- Raw API tester — text input + GET button against any /api/sfm/*
path, response rendered as prettified JSON
New /admin/slmm page — same layout, stripped down to health + connection
+ raw API tester. For per-device SLM control the existing
/sound-level-meters dashboard remains the right entry point.
Backend (backend/routers/admin_modules.py):
- GET /admin/sfm, GET /admin/slmm — HTML pages
- GET /api/admin/sfm/overview — single aggregated probe that returns
health, units, last 25 events with computed latency, stale-table
counts, cache stats. Tolerant of partial failures: any sub-fetch
error is captured into errors{} so a flaky SFM endpoint doesn't
break the whole page
- GET /api/admin/slmm/overview — health + connection info only for now
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
6.5 KiB
HTML
139 lines
6.5 KiB
HTML
{% 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 %}
|