feat(sfm): wire SFM events into project-location detail page
Phase 1 of the SFM project/location integration. When viewing a vibration
monitoring location, operators now see the events that were actually
recorded there — fanned out across every seismograph that was ever
assigned to that location (handles mid-project unit swaps).
Backend:
- backend/services/sfm_events.py: new events_for_location() async helper.
Walks UnitAssignment rows for the location (active + closed), intersects
each assignment's [assigned_at, assigned_until] window with the requested
filter, and concurrently queries SFM /db/events for each (serial, window)
pair via httpx.AsyncClient. Unions, sorts newest-first, computes summary
stats (event count, peak PVS + when/who, last event, false-trigger count)
over the full set, and trims to the user's display limit. Over-fetches
per-window (up to 5000) so stats stay accurate even with a small display
limit.
- backend/routers/project_locations.py: new GET endpoint
/api/projects/{project_id}/locations/{location_id}/events. Validates
project/location pairing (404 on mismatch). SLM locations return an
empty payload rather than 404 so the frontend can render gracefully.
Frontend:
- templates/vibration_location_detail.html: new "Events" tab on the
location detail page. KPI tiles (total / peak PVS / last event / false
triggers), "Seismographs deployed at this location" assignment list
(transparency: shows each assignment's date range and contributed event
count), date / false-trigger / limit filters, and the paginated event
table. Lazy-loaded on first tab visit; manual refresh button.
Architectural notes:
- SFM remains the single source of truth for events. No event sync; live
HTTP per page load.
- UnitAssignment is the join key (not MonitoringSession).
- Events whose timestamp falls outside every assignment window are NOT
surfaced here. Those orphan events get a dedicated "Unattributed
events" view on the per-unit detail page in Phase 2.
Out of scope (this commit):
- Phase 2 (per-unit history view) and Phase 3 (project-level roll-up)
reuse this helper but ship separately.
- Phase 4 (deprecating deployment_records) is independent.
- Extracting the event-table JS to a shared file is a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -648,6 +648,68 @@ async def get_nrl_sessions(
|
||||
})
|
||||
|
||||
|
||||
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
|
||||
async def get_location_events(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
from_dt: Optional[datetime] = Query(None),
|
||||
to_dt: Optional[datetime] = Query(None),
|
||||
false_trigger: Optional[bool] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Return SFM events recorded at this monitoring location.
|
||||
|
||||
Fans out the location's UnitAssignment rows (every seismograph ever
|
||||
assigned to this location, active + closed), queries SFM /db/events
|
||||
for each (serial, time-window) pair concurrently, and unions the
|
||||
results.
|
||||
|
||||
Sound (SLM) locations return an empty payload — SFM events are
|
||||
seismograph-only.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found.")
|
||||
if location.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Location does not belong to this project.",
|
||||
)
|
||||
|
||||
# SLM locations don't have SFM events — return an empty payload rather
|
||||
# than 404 so the frontend can render an empty state gracefully.
|
||||
if location.location_type != "vibration":
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"event_count": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_serial": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
},
|
||||
"assignments_used": [],
|
||||
"location_type": location.location_type,
|
||||
}
|
||||
|
||||
from backend.services.sfm_events import events_for_location
|
||||
|
||||
result = await events_for_location(
|
||||
db,
|
||||
location_id,
|
||||
from_dt=from_dt,
|
||||
to_dt=to_dt,
|
||||
false_trigger=false_trigger,
|
||||
limit=limit,
|
||||
)
|
||||
result["location_type"] = location.location_type
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
|
||||
async def get_nrl_files(
|
||||
project_id: str,
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
SFM events service — bridge between terra-view's UnitAssignment time-windows
|
||||
and the SFM (seismo-relay) events store.
|
||||
|
||||
Architecture:
|
||||
1. Terra-view owns the *assignment graph*: which seismograph was at which
|
||||
monitoring location during which time window (UnitAssignment rows).
|
||||
2. SFM owns the *events store*: triggered waveform events keyed by
|
||||
(serial, timestamp), forwarded from Blastware ACH by series3-watcher.
|
||||
3. This module fans out the assignments for a given location, queries SFM
|
||||
for the events emitted by each (serial, window) pair concurrently, and
|
||||
unions/sorts/paginates the results.
|
||||
|
||||
SFM remains the single source of truth for events. Terra-view does not
|
||||
copy events into its own DB; every query hits SFM live.
|
||||
|
||||
The events_for_location helper is also reused by Phase 3 (project-level
|
||||
roll-up) to aggregate across every location in a project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import UnitAssignment, RosterUnit
|
||||
|
||||
log = logging.getLogger("backend.services.sfm_events")
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
# Per-request timeout when calling SFM /db/events. SFM is local on the
|
||||
# docker network so this should be fast; bump if you start seeing timeouts.
|
||||
_SFM_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Max events we ever fetch per (serial, window) call to SFM. Must match
|
||||
# SFM's own /db/events max limit (currently 5000). The user-facing display
|
||||
# limit is independent — we over-fetch up to this cap so summary stats are
|
||||
# accurate, then trim the displayed list to the requested limit.
|
||||
_SFM_FETCH_CEILING = 5000
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _iso_utc(dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Render a datetime in the ISO format SFM /db/events expects."""
|
||||
if dt is None:
|
||||
return None
|
||||
# SFM parses naive ISO strings as UTC; strip tzinfo for consistency.
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt.isoformat(sep=" ", timespec="seconds")
|
||||
|
||||
|
||||
def _intersect_window(
|
||||
assignment_start: datetime,
|
||||
assignment_end: Optional[datetime],
|
||||
filter_from: Optional[datetime],
|
||||
filter_to: Optional[datetime],
|
||||
now: datetime,
|
||||
) -> Optional[tuple[datetime, datetime]]:
|
||||
"""Intersect an assignment window with the requested filter window.
|
||||
|
||||
Returns (effective_start, effective_end) or None if there's no overlap.
|
||||
Open-ended assignments (assigned_until=NULL) are bounded by `now`.
|
||||
"""
|
||||
a_end = assignment_end or now
|
||||
if filter_from and a_end <= filter_from:
|
||||
return None
|
||||
if filter_to and assignment_start >= filter_to:
|
||||
return None
|
||||
start = max(assignment_start, filter_from) if filter_from else assignment_start
|
||||
end = min(a_end, filter_to) if filter_to else a_end
|
||||
if end <= start:
|
||||
return None
|
||||
return (start, end)
|
||||
|
||||
|
||||
async def _fetch_events_for_serial(
|
||||
client: httpx.AsyncClient,
|
||||
serial: str,
|
||||
*,
|
||||
from_dt: datetime,
|
||||
to_dt: datetime,
|
||||
false_trigger: Optional[bool],
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Issue one /db/events call to SFM for one (serial, window) pair."""
|
||||
params: dict[str, str] = {
|
||||
"serial": serial,
|
||||
"from_dt": _iso_utc(from_dt) or "",
|
||||
"to_dt": _iso_utc(to_dt) or "",
|
||||
"limit": str(limit),
|
||||
}
|
||||
if false_trigger is not None:
|
||||
params["false_trigger"] = "true" if false_trigger else "false"
|
||||
|
||||
try:
|
||||
resp = await client.get(f"{SFM_BASE_URL}/db/events", params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/events failed for serial=%s: %s", serial, e)
|
||||
return []
|
||||
|
||||
payload = resp.json()
|
||||
events = payload.get("events", []) or []
|
||||
# Strip waveform_blob if present — it's the big per-event binary and we
|
||||
# don't render it in the list view. SFM returns it by default.
|
||||
for ev in events:
|
||||
ev.pop("waveform_blob", None)
|
||||
ev.pop("a5_pickle_filename", None)
|
||||
return events
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def events_for_location(
|
||||
db: Session,
|
||||
location_id: str,
|
||||
*,
|
||||
from_dt: Optional[datetime] = None,
|
||||
to_dt: Optional[datetime] = None,
|
||||
false_trigger: Optional[bool] = None,
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""Fan out UnitAssignment rows for `location_id` and union SFM events.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"events": [merged event dicts, newest first, capped at limit],
|
||||
"count": total events found across all windows (pre-cap),
|
||||
"stats": {event_count, peak_pvs, peak_pvs_at,
|
||||
last_event, false_trigger_count},
|
||||
"assignments_used": [{unit_id, assigned_at, assigned_until,
|
||||
events_in_window}, ...],
|
||||
}
|
||||
|
||||
The "events outside any assignment window" rule (Phase 1 design decision):
|
||||
events whose timestamp falls outside every assignment window are simply
|
||||
not fetched — we only ask SFM for events inside the intersected windows.
|
||||
Those orphan events surface under the per-unit detail page in Phase 2.
|
||||
"""
|
||||
# 1. Fetch all assignments (active + closed) for the location.
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == location_id)
|
||||
.filter(UnitAssignment.device_type == "seismograph")
|
||||
.order_by(UnitAssignment.assigned_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not assignments:
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": _empty_stats(),
|
||||
"assignments_used": [],
|
||||
}
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 2. For each assignment, compute the effective (start, end) window after
|
||||
# intersecting with the requested filter range. Drop assignments that
|
||||
# don't overlap the filter window.
|
||||
fetch_specs: list[tuple[UnitAssignment, datetime, datetime]] = []
|
||||
for a in assignments:
|
||||
window = _intersect_window(a.assigned_at, a.assigned_until, from_dt, to_dt, now)
|
||||
if window is not None:
|
||||
fetch_specs.append((a, window[0], window[1]))
|
||||
|
||||
if not fetch_specs:
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": _empty_stats(),
|
||||
"assignments_used": [
|
||||
{
|
||||
"unit_id": a.unit_id,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
"events_in_window": 0,
|
||||
}
|
||||
for a in assignments
|
||||
],
|
||||
}
|
||||
|
||||
# 3. Concurrent SFM fetches. We over-fetch (up to _SFM_FETCH_CEILING per
|
||||
# window) so summary stats reflect the true peak/last/count across the
|
||||
# full filter window, not just what fits in the user's display limit.
|
||||
# The displayed event list is trimmed to `limit` after merge.
|
||||
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
|
||||
per_window_lists = await asyncio.gather(
|
||||
*(
|
||||
_fetch_events_for_serial(
|
||||
client,
|
||||
serial=a.unit_id,
|
||||
from_dt=start,
|
||||
to_dt=end,
|
||||
false_trigger=false_trigger,
|
||||
limit=_SFM_FETCH_CEILING,
|
||||
)
|
||||
for a, start, end in fetch_specs
|
||||
),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
# 4. Build the per-assignment event counts (transparency for the operator).
|
||||
spec_event_counts: dict[str, int] = {}
|
||||
for (a, _start, _end), evs in zip(fetch_specs, per_window_lists):
|
||||
spec_event_counts[a.id] = len(evs)
|
||||
|
||||
# 5. Union, sort newest-first, cap.
|
||||
merged: list[dict] = []
|
||||
for evs in per_window_lists:
|
||||
merged.extend(evs)
|
||||
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||
total_count = len(merged)
|
||||
capped = merged[:limit]
|
||||
|
||||
# 6. Compute summary stats over the full merged set (not the capped one).
|
||||
stats = _compute_stats(merged)
|
||||
|
||||
# 7. Build the assignments_used report (every assignment, in chronological
|
||||
# order, with its event count — even ones that fell outside the filter
|
||||
# window so the operator sees them but with count=0).
|
||||
assignments_used = []
|
||||
for a in assignments:
|
||||
assignments_used.append(
|
||||
{
|
||||
"unit_id": a.unit_id,
|
||||
"assignment_id": a.id,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
"events_in_window": spec_event_counts.get(a.id, 0),
|
||||
"status": a.status,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"events": capped,
|
||||
"count": total_count,
|
||||
"stats": stats,
|
||||
"assignments_used": assignments_used,
|
||||
}
|
||||
|
||||
|
||||
# ── Stats helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _empty_stats() -> dict:
|
||||
return {
|
||||
"event_count": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_serial": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _compute_stats(events: list[dict]) -> dict:
|
||||
"""Roll up summary stats from a merged event list. Cheap O(N) pass."""
|
||||
if not events:
|
||||
return _empty_stats()
|
||||
|
||||
peak_pvs = None
|
||||
peak_pvs_at = None
|
||||
peak_pvs_serial = None
|
||||
last_event = None
|
||||
false_trigger_count = 0
|
||||
|
||||
for ev in events:
|
||||
pvs = ev.get("peak_vector_sum")
|
||||
if pvs is not None and (peak_pvs is None or pvs > peak_pvs):
|
||||
peak_pvs = pvs
|
||||
peak_pvs_at = ev.get("timestamp")
|
||||
peak_pvs_serial = ev.get("serial")
|
||||
|
||||
ts = ev.get("timestamp")
|
||||
if ts and (last_event is None or ts > last_event):
|
||||
last_event = ts
|
||||
|
||||
if ev.get("false_trigger"):
|
||||
false_trigger_count += 1
|
||||
|
||||
return {
|
||||
"event_count": len(events),
|
||||
"peak_pvs": peak_pvs,
|
||||
"peak_pvs_at": peak_pvs_at,
|
||||
"peak_pvs_serial": peak_pvs_serial,
|
||||
"last_event": last_event,
|
||||
"false_trigger_count": false_trigger_count,
|
||||
}
|
||||
@@ -65,6 +65,11 @@
|
||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||
Overview
|
||||
</button>
|
||||
<button onclick="switchTab('events')"
|
||||
data-tab="events"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Events
|
||||
</button>
|
||||
<button onclick="switchTab('settings')"
|
||||
data-tab="settings"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
@@ -185,6 +190,92 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Tab -->
|
||||
<div id="events-tab" class="tab-panel hidden">
|
||||
<!-- Summary stats -->
|
||||
<div id="events-stats" 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 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||
<span id="ev-stat-count" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
|
||||
<span id="ev-stat-peak" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span id="ev-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||
<span id="ev-stat-last" class="text-lg font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
|
||||
<span id="ev-stat-ft" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignments used (transparency: which seismographs contributed events) -->
|
||||
<div id="events-assignments-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Seismographs deployed at this location</h3>
|
||||
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6">
|
||||
<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">From</label>
|
||||
<input type="datetime-local" id="ev-filter-from" onchange="loadLocationEvents()"
|
||||
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-filter-to" onchange="loadLocationEvents()"
|
||||
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-filter-ft" onchange="loadLocationEvents()"
|
||||
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-filter-limit" onchange="loadLocationEvents()"
|
||||
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>
|
||||
<option value="1000">1000</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
|
||||
</button>
|
||||
<button onclick="loadLocationEvents()"
|
||||
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event table -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="settings-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
@@ -324,6 +415,175 @@ function switchTab(tabName) {
|
||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
// Lazy-load Events tab on first visit (or whenever it's reopened).
|
||||
if (tabName === 'events' && !_eventsLoaded) {
|
||||
loadLocationEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events tab ───────────────────────────────────────────────────────────────
|
||||
let _eventsLoaded = false;
|
||||
|
||||
function clearEventFilters() {
|
||||
document.getElementById('ev-filter-from').value = '';
|
||||
document.getElementById('ev-filter-to').value = '';
|
||||
document.getElementById('ev-filter-ft').value = '';
|
||||
document.getElementById('ev-filter-limit').value = '500';
|
||||
loadLocationEvents();
|
||||
}
|
||||
|
||||
async function loadLocationEvents() {
|
||||
const container = document.getElementById('events-container');
|
||||
container.innerHTML = '<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>';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const from = document.getElementById('ev-filter-from').value;
|
||||
const to = document.getElementById('ev-filter-to').value;
|
||||
const ft = document.getElementById('ev-filter-ft').value;
|
||||
const limit = document.getElementById('ev-filter-limit').value;
|
||||
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/projects/${projectId}/locations/${locationId}/events?${params.toString()}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
const d = await r.json();
|
||||
_eventsLoaded = true;
|
||||
renderEventStats(d.stats);
|
||||
renderAssignmentsUsed(d.assignments_used);
|
||||
renderEventTable(d.events, d.count, container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEventStats(stats) {
|
||||
const s = stats || {};
|
||||
document.getElementById('ev-stat-count').textContent = (s.event_count ?? 0).toLocaleString();
|
||||
document.getElementById('ev-stat-ft').textContent = (s.false_trigger_count ?? 0).toLocaleString();
|
||||
|
||||
if (s.peak_pvs != null) {
|
||||
document.getElementById('ev-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
|
||||
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
|
||||
const who = s.peak_pvs_serial || '';
|
||||
document.getElementById('ev-stat-peak-when').textContent = [when, who].filter(Boolean).join(' · ') || '—';
|
||||
} else {
|
||||
document.getElementById('ev-stat-peak').textContent = '—';
|
||||
document.getElementById('ev-stat-peak-when').textContent = '—';
|
||||
}
|
||||
|
||||
if (s.last_event) {
|
||||
const dt = s.last_event.slice(0, 19).replace('T', ' ');
|
||||
document.getElementById('ev-stat-last').textContent = dt;
|
||||
} else {
|
||||
document.getElementById('ev-stat-last').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignmentsUsed(assignments) {
|
||||
const card = document.getElementById('events-assignments-card');
|
||||
const listEl = document.getElementById('ev-assignments-list');
|
||||
const countEl = document.getElementById('ev-assignments-count');
|
||||
|
||||
if (!assignments || assignments.length === 0) {
|
||||
card.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
card.classList.remove('hidden');
|
||||
countEl.textContent = `${assignments.length} assignment${assignments.length === 1 ? '' : 's'}`;
|
||||
|
||||
listEl.innerHTML = assignments.map(a => {
|
||||
const start = a.assigned_at ? a.assigned_at.slice(0, 10) : '?';
|
||||
const end = a.assigned_until ? a.assigned_until.slice(0, 10) : 'present';
|
||||
const isActive = !a.assigned_until;
|
||||
const badge = isActive
|
||||
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
return `<div class="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
|
||||
${badge}
|
||||
<span class="ml-3 text-sm text-gray-600 dark:text-gray-400">${start} → ${end}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderEventTable(events, total, container) {
|
||||
if (!events || events.length === 0) {
|
||||
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');
|
||||
const msg = haveAssignments
|
||||
? 'No events recorded for the assignments above within the current filter.'
|
||||
: 'No seismographs have been assigned to this location yet. Assign one to start collecting events.';
|
||||
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</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/30 dark:text-yellow-300">FT</span>'
|
||||
: '';
|
||||
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<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">
|
||||
<a href="/unit/${esc(ev.serial)}" class="hover:text-seismo-navy">${esc(ev.serial)}</a>
|
||||
</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}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3">Showing ${events.length} of ${total.toLocaleString()} 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">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 'text-green-600 dark:text-green-400';
|
||||
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Location settings form submission
|
||||
|
||||
Reference in New Issue
Block a user