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:
2026-05-11 21:57:14 +00:00
parent a71e6f5efd
commit df771a87de
3 changed files with 623 additions and 0 deletions
+62
View File
@@ -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,