merge v0.12.0 #51
@@ -49,6 +49,15 @@ def admin_sfm_page(request: Request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/events", response_class=HTMLResponse)
|
||||||
|
def admin_events_page(request: Request):
|
||||||
|
"""SFM Event DB Manager — browse, flag, and delete events across all units."""
|
||||||
|
return templates.TemplateResponse("admin_events.html", {
|
||||||
|
"request": request,
|
||||||
|
"sfm_base_url": SFM_BASE_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/sfm/overview")
|
@router.get("/api/admin/sfm/overview")
|
||||||
async def admin_sfm_overview() -> JSONResponse:
|
async def admin_sfm_overview() -> JSONResponse:
|
||||||
"""Aggregated SFM diagnostic snapshot.
|
"""Aggregated SFM diagnostic snapshot.
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SFM Event DB Manager - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6 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 Event DB Manager</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Browse, flag, and delete triggered events from SFM's events table. Destructive actions also clean up on-disk waveform / sidecar / pickle / hdf5 files.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadEvents()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning banner -->
|
||||||
|
<div class="rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-2xl">⚠️</span>
|
||||||
|
<div class="text-sm text-red-900 dark:text-red-200">
|
||||||
|
<strong class="font-semibold">Destructive operations.</strong>
|
||||||
|
Delete actions remove rows from SFM's events table AND delete associated waveform files on disk. Both are permanent — there is no undo. Use filters carefully, dry-run first, and verify the match count before confirming.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-5 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Filters</h2>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Serial</label>
|
||||||
|
<input type="text" id="f-serial" placeholder="e.g. BE9558"
|
||||||
|
onkeydown="if (event.key === 'Enter') loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm font-mono w-40 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">From</label>
|
||||||
|
<input type="datetime-local" id="f-from"
|
||||||
|
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="f-to"
|
||||||
|
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="f-ft" 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</option>
|
||||||
|
<option value="false">Real Only</option>
|
||||||
|
<option value="true">FT 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="f-limit" 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="500" selected>500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
<option value="5000">5000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadEvents()"
|
||||||
|
class="px-4 py-1.5 text-sm font-medium rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button onclick="clearFilters()"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk actions -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span id="bulk-selected" class="text-sm text-gray-600 dark:text-gray-400">0 selected</span>
|
||||||
|
|
||||||
|
<button id="bulk-delete-selected" onclick="deleteSelected()" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
🗑 Delete selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="deleteByFilter()"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-red-400 dark:border-red-600 bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 hover:bg-red-100 dark:hover:bg-red-900/50 font-medium">
|
||||||
|
🗑 Delete ALL matching current filter…
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="border-l border-gray-300 dark:border-gray-600 h-6"></div>
|
||||||
|
|
||||||
|
<button id="bulk-flag-ft" onclick="flagSelected(true)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
🚩 Flag as FT
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="bulk-clear-ft" onclick="flagSelected(false)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
✓ Clear FT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700" id="results-summary">
|
||||||
|
Apply filters above to load events.
|
||||||
|
</div>
|
||||||
|
<div id="results-table" class="overflow-x-auto">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No query run yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SFM_PROXY = '/api/sfm';
|
||||||
|
const _selected = new Set();
|
||||||
|
let _events = [];
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
function _fmtPpv(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); }
|
||||||
|
|
||||||
|
function _refreshBulkButtons() {
|
||||||
|
const n = _selected.size;
|
||||||
|
document.getElementById('bulk-selected').textContent = `${n} selected`;
|
||||||
|
document.getElementById('bulk-delete-selected').disabled = (n === 0);
|
||||||
|
document.getElementById('bulk-flag-ft').disabled = (n === 0);
|
||||||
|
document.getElementById('bulk-clear-ft').disabled = (n === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _currentFilter() {
|
||||||
|
const f = {};
|
||||||
|
const serial = document.getElementById('f-serial').value.trim();
|
||||||
|
const from = document.getElementById('f-from').value;
|
||||||
|
const to = document.getElementById('f-to').value;
|
||||||
|
const ft = document.getElementById('f-ft').value;
|
||||||
|
if (serial) f.serial = serial;
|
||||||
|
if (from) f.from_dt = from;
|
||||||
|
if (to) f.to_dt = to;
|
||||||
|
if (ft === 'true') f.false_trigger = true;
|
||||||
|
if (ft === 'false') f.false_trigger = false;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
document.getElementById('f-serial').value = '';
|
||||||
|
document.getElementById('f-from').value = '';
|
||||||
|
document.getElementById('f-to').value = '';
|
||||||
|
document.getElementById('f-ft').value = '';
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const f = _currentFilter();
|
||||||
|
const limit = document.getElementById('f-limit').value || '500';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (f.serial) params.set('serial', f.serial);
|
||||||
|
if (f.from_dt) params.set('from_dt', f.from_dt);
|
||||||
|
if (f.to_dt) params.set('to_dt', f.to_dt);
|
||||||
|
if (f.false_trigger !== undefined) params.set('false_trigger', String(f.false_trigger));
|
||||||
|
params.set('limit', limit);
|
||||||
|
|
||||||
|
const container = document.getElementById('results-table');
|
||||||
|
const summary = document.getElementById('results-summary');
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events?${params.toString()}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: HTTP ${resp.status}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
_events = data.events || [];
|
||||||
|
summary.textContent = `Showing ${_events.length} event${_events.length === 1 ? '' : 's'} (limit ${limit})`;
|
||||||
|
renderTable();
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: ${_esc(err.message || err)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const container = document.getElementById('results-table');
|
||||||
|
if (_events.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">No events match the current filter.</div>';
|
||||||
|
_refreshBulkButtons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = _events.map(ev => {
|
||||||
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||||
|
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/30 dark:text-yellow-300">FT</span>'
|
||||||
|
: '';
|
||||||
|
const checked = _selected.has(ev.id) ? 'checked' : '';
|
||||||
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||||
|
<td class="px-3 py-2"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.vert_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.long_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right font-semibold">${_fmtPpv(ev.peak_vector_sum)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm">${ft}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-gray-500 dark:text-gray-400 truncate" style="max-width:240px;" title="${_esc(ev.id)}">${_esc(ev.id).slice(0, 8)}…</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<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-3 py-2"><input type="checkbox" id="check-all" onchange="toggleAllRows(this.checked)"></th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Serial</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Timestamp</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Tran</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Vert</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Long</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">PVS</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Flags</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowCheck(input) {
|
||||||
|
const id = input.getAttribute('data-event-id');
|
||||||
|
if (input.checked) _selected.add(id);
|
||||||
|
else {
|
||||||
|
_selected.delete(id);
|
||||||
|
const master = document.getElementById('check-all');
|
||||||
|
if (master) master.checked = false;
|
||||||
|
}
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllRows(checked) {
|
||||||
|
document.querySelectorAll('.row-check').forEach(cb => {
|
||||||
|
const id = cb.getAttribute('data-event-id');
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) _selected.add(id);
|
||||||
|
else _selected.delete(id);
|
||||||
|
});
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (_selected.size === 0) return;
|
||||||
|
const ids = Array.from(_selected);
|
||||||
|
if (!confirm(`PERMANENTLY delete ${ids.length} event${ids.length === 1 ? '' : 's'} from the SFM DB?\n\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids, confirm: true }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Delete failed: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteByFilter() {
|
||||||
|
const f = _currentFilter();
|
||||||
|
if (Object.keys(f).length === 0) {
|
||||||
|
if (!confirm('No filters set — this would attempt to delete EVERY event in the SFM DB.\n\nAre you absolutely sure? You probably want a serial filter at minimum.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dry-run first
|
||||||
|
let matched = 0;
|
||||||
|
let sample_serials = [];
|
||||||
|
try {
|
||||||
|
const dry = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(f),
|
||||||
|
});
|
||||||
|
const dryData = await dry.json();
|
||||||
|
if (!dry.ok) {
|
||||||
|
alert(`Dry-run failed: HTTP ${dry.status}\n${JSON.stringify(dryData)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matched = dryData.matched || 0;
|
||||||
|
sample_serials = dryData.sample_serials || [];
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Dry-run failed: ${err.message || err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matched === 0) {
|
||||||
|
alert('No events match the current filter — nothing to delete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLines = Object.entries(f).map(([k, v]) => ` ${k} = ${v}`).join('\n') || ' (none)';
|
||||||
|
const serialList = sample_serials.length ? ` serials: ${sample_serials.join(', ')}${sample_serials.length === 5 ? ' (and possibly more)' : ''}\n` : '';
|
||||||
|
if (!confirm(`PERMANENTLY delete ${matched} event${matched === 1 ? '' : 's'}?\n\nFilter:\n${filterLines}\n${serialList}\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...f, confirm: true }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.status === 'too_many') {
|
||||||
|
alert(`Refused: ${data.hint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Delete failed: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flagSelected(value) {
|
||||||
|
if (_selected.size === 0) return;
|
||||||
|
const ids = Array.from(_selected);
|
||||||
|
const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on';
|
||||||
|
if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) return;
|
||||||
|
let ok = 0, failed = 0, cursor = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < ids.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
const id = ids[i];
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${SFM_PROXY}/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`,
|
||||||
|
{ method: 'PATCH' });
|
||||||
|
if (r.ok) ok++; else failed++;
|
||||||
|
} catch (_) { failed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: 8 }, worker));
|
||||||
|
if (failed) alert(`${ok} updated, ${failed} failed.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial empty state — let the user choose to load.
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -589,6 +589,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SFM Event DB Manager -->
|
||||||
|
<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 Event DB Manager</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Browse, flag, and <strong>delete</strong> events from SFM's events table across all units. Destructive — also cleans up on-disk waveform files. Use for cleaning bogus events from a misbehaving unit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/events"
|
||||||
|
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
|
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
||||||
operator workflows, not admin/dev surfaces). Find them
|
operator workflows, not admin/dev surfaces). Find them
|
||||||
at /tools. #}
|
at /tools. #}
|
||||||
|
|||||||
@@ -379,6 +379,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk false-trigger flagging -->
|
||||||
|
<div id="ue-bulk-actions" class="flex flex-wrap items-center gap-2 mb-3 text-sm">
|
||||||
|
<span id="ue-bulk-selected" class="text-gray-600 dark:text-gray-400">0 selected</span>
|
||||||
|
<button id="ue-bulk-flag-ft" onclick="flagSelectedUnitEvents(true)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
🚩 Flag as false trigger
|
||||||
|
</button>
|
||||||
|
<button id="ue-bulk-clear-ft" onclick="flagSelectedUnitEvents(false)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
✓ Clear false trigger
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">For deletion / DB cleanup, use the <a href="/admin/events" class="text-seismo-orange hover:underline">Event DB Manager</a>.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Event table -->
|
<!-- Event table -->
|
||||||
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
@@ -2652,7 +2666,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const checked = _ueSelectedEventIds.has(ev.id) ? 'checked' : '';
|
||||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}" onclick="showEventDetail('${_dtEsc(ev.id)}')">
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}" onclick="showEventDetail('${_dtEsc(ev.id)}')">
|
||||||
|
<td class="px-3 py-2.5 text-sm" onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" class="ue-row-check rounded border-gray-300 dark:border-gray-600"
|
||||||
|
data-event-id="${_dtEsc(ev.id)}" ${checked}
|
||||||
|
onchange="onUnitEventRowCheck(this)">
|
||||||
|
</td>
|
||||||
<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 text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
|
||||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
||||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
||||||
@@ -2668,6 +2688,11 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
<table class="w-full text-left">
|
<table class="w-full text-left">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-3 py-2">
|
||||||
|
<input type="checkbox" id="ue-check-all"
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
onchange="toggleAllUnitEventRows(this.checked)">
|
||||||
|
</th>
|
||||||
${_ueSortableTh('Timestamp', 'timestamp')}
|
${_ueSortableTh('Timestamp', 'timestamp')}
|
||||||
${_ueSortableTh('Tran', 'tran_ppv')}
|
${_ueSortableTh('Tran', 'tran_ppv')}
|
||||||
${_ueSortableTh('Vert', 'vert_ppv')}
|
${_ueSortableTh('Vert', 'vert_ppv')}
|
||||||
@@ -2679,6 +2704,85 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
|
_ueRefreshBulkButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Bulk false-trigger flagging =====
|
||||||
|
// Selection is keyed by event ID and persists across table re-renders, so
|
||||||
|
// users can paginate / re-sort without losing their selection.
|
||||||
|
const _ueSelectedEventIds = new Set();
|
||||||
|
|
||||||
|
function _ueRefreshBulkButton() {
|
||||||
|
const n = _ueSelectedEventIds.size;
|
||||||
|
const lbl = document.getElementById('ue-bulk-selected');
|
||||||
|
const flag = document.getElementById('ue-bulk-flag-ft');
|
||||||
|
const clr = document.getElementById('ue-bulk-clear-ft');
|
||||||
|
if (lbl) lbl.textContent = `${n} selected`;
|
||||||
|
if (flag) flag.disabled = (n === 0);
|
||||||
|
if (clr) clr.disabled = (n === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUnitEventRowCheck(input) {
|
||||||
|
const id = input.getAttribute('data-event-id');
|
||||||
|
if (input.checked) {
|
||||||
|
_ueSelectedEventIds.add(id);
|
||||||
|
} else {
|
||||||
|
_ueSelectedEventIds.delete(id);
|
||||||
|
// If we just unchecked a row, the master "all" checkbox shouldn't stay checked.
|
||||||
|
const master = document.getElementById('ue-check-all');
|
||||||
|
if (master) master.checked = false;
|
||||||
|
}
|
||||||
|
_ueRefreshBulkButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllUnitEventRows(checked) {
|
||||||
|
document.querySelectorAll('.ue-row-check').forEach(cb => {
|
||||||
|
const id = cb.getAttribute('data-event-id');
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) _ueSelectedEventIds.add(id);
|
||||||
|
else _ueSelectedEventIds.delete(id);
|
||||||
|
});
|
||||||
|
_ueRefreshBulkButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flagSelectedUnitEvents(value) {
|
||||||
|
// value = true → flag as false trigger
|
||||||
|
// value = false → clear false-trigger flag
|
||||||
|
if (_ueSelectedEventIds.size === 0) return;
|
||||||
|
const ids = Array.from(_ueSelectedEventIds);
|
||||||
|
const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on';
|
||||||
|
if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFM exposes single-row PATCH only. Fan out concurrently with a
|
||||||
|
// modest cap so we don't open hundreds of sockets at once.
|
||||||
|
const concurrency = 8;
|
||||||
|
let ok = 0, failed = 0;
|
||||||
|
let cursor = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < ids.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
const id = ids[i];
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/sfm/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`,
|
||||||
|
{ method: 'PATCH' }
|
||||||
|
);
|
||||||
|
if (resp.ok) ok++;
|
||||||
|
else failed++;
|
||||||
|
} catch (_) {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
alert(`${ok} updated, ${failed} failed. Refreshing table.`);
|
||||||
|
}
|
||||||
|
_ueSelectedEventIds.clear();
|
||||||
|
loadUnitEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Pair Device Modal Functions =====
|
// ===== Pair Device Modal Functions =====
|
||||||
|
|||||||
Reference in New Issue
Block a user