bc5a151faa
Phase 2 of the SFM integration. Adds a "SFM Events" section to the
seismograph unit detail page (/unit/{id}). Every event SFM has for the
serial is shown, with each event annotated by which project/location
assignment window it falls into. Events outside every assignment window
get the "⚠ Unattributed" badge plus a "<N>d before/after <nearest location>"
hint — that's the operator's signal that backdating an assignment (Phase 1
edit-pencil) will absorb the orphan events.
Backend:
- backend/services/sfm_events.py: new events_for_unit() helper. Fetches
all events for the serial via SFM /db/events (one call, ceiling 5000),
loads every UnitAssignment for the unit + resolves MonitoringLocation +
Project names, then annotates each event with attribution or
nearest_assignment (signed delta_days). Bucket filter: all /
attributed / unattributed. Stats always reflect the full event set so
the "Unattributed" KPI tile is meaningful regardless of which bucket
is being viewed.
- backend/routers/units.py: new GET /api/units/{unit_id}/events with
bucket / date-range / false_trigger / limit query params. 404s on
unknown unit_id; returns an empty payload for non-seismograph
device_types so the page can render the section conditionally.
Frontend (templates/unit_detail.html):
- New "SFM Events" section between "Deployment History" and "Timeline",
styled to match the existing card pattern (border-t divider, same
heading weight).
- Hidden by default; revealed only when currentUnit.device_type ===
'seismograph' after the unit data loads.
- Four KPI tiles: Total Events / Unattributed (highlighted amber when
> 0) / Peak PVS / Last Event.
- Filters: Bucket (all|attributed|unattributed), From/To, False
Triggers, Limit, + Refresh.
- Event table with Attribution column. Attributed rows link to the
project/location detail page; unattributed rows are tinted amber
and show "<N>d before/after <nearest location>" with a link to the
nearest location.
- Empty-state copy varies by bucket: e.g. unattributed-with-zero shows
"✅ All events for this unit are attributed to a project/location".
Verified end-to-end against BE11529 (81 events total, 24 attributed,
57 unattributed — all 57 unattributed events emitted within hours of
the assignment start, which means backdating the assignment by a day
would attribute every one of them).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
4.8 KiB
Python
139 lines
4.8 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional
|
|
|
|
from backend.database import get_db
|
|
from backend.services.snapshot import emit_status_snapshot
|
|
from backend.models import RosterUnit
|
|
|
|
router = APIRouter(prefix="/api", tags=["units"])
|
|
|
|
|
|
@router.get("/unit/{unit_id}")
|
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Returns detailed data for a single unit.
|
|
"""
|
|
snapshot = emit_status_snapshot()
|
|
|
|
if unit_id not in snapshot["units"]:
|
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
|
|
|
unit_data = snapshot["units"][unit_id]
|
|
|
|
# Mock coordinates for now (will be replaced with real data)
|
|
mock_coords = {
|
|
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
|
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
|
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
|
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
|
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
|
}
|
|
|
|
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
|
|
|
return {
|
|
"id": unit_id,
|
|
"status": unit_data["status"],
|
|
"age": unit_data["age"],
|
|
"last_seen": unit_data["last"],
|
|
"last_file": unit_data.get("fname", ""),
|
|
"deployed": unit_data["deployed"],
|
|
"note": unit_data.get("note", ""),
|
|
"coordinates": coords
|
|
}
|
|
|
|
|
|
@router.get("/units/{unit_id}")
|
|
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Get unit data directly from the roster (for settings/configuration).
|
|
"""
|
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
|
|
|
if not unit:
|
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
|
|
|
return {
|
|
"id": unit.id,
|
|
"unit_type": unit.unit_type,
|
|
"device_type": unit.device_type,
|
|
"deployed": unit.deployed,
|
|
"retired": unit.retired,
|
|
"note": unit.note,
|
|
"location": unit.location,
|
|
"address": unit.address,
|
|
"coordinates": unit.coordinates,
|
|
"slm_host": unit.slm_host,
|
|
"slm_tcp_port": unit.slm_tcp_port,
|
|
"slm_ftp_port": unit.slm_ftp_port,
|
|
"slm_model": unit.slm_model,
|
|
"slm_serial_number": unit.slm_serial_number,
|
|
"deployed_with_modem_id": unit.deployed_with_modem_id
|
|
}
|
|
|
|
|
|
@router.get("/units/{unit_id}/events")
|
|
async def get_unit_events(
|
|
unit_id: str,
|
|
bucket: str = Query("all", regex="^(all|attributed|unattributed)$"),
|
|
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 for a single unit, annotated with assignment attribution.
|
|
|
|
Each event includes an `attribution` object pointing at the project/location
|
|
it falls into (or null if outside every assignment window). Unattributed
|
|
events also carry a `nearest_assignment` field with `delta_days` so the
|
|
operator can see how far off the nearest assignment is — useful for
|
|
deciding whether to backdate the assignment to absorb the event.
|
|
|
|
Bucket filter:
|
|
- all (default): every event
|
|
- attributed: only events inside an assignment window
|
|
- unattributed: only orphan events (the diagnostic bucket)
|
|
|
|
Non-seismograph units return an empty events list. The route does not
|
|
404 for SLMs/modems so the unit detail page can render the section
|
|
conditionally without depending on the response shape.
|
|
"""
|
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
|
if not unit:
|
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
|
|
|
if unit.device_type != "seismograph":
|
|
return {
|
|
"events": [],
|
|
"count": 0,
|
|
"stats": {
|
|
"event_count": 0,
|
|
"unattributed_count": 0,
|
|
"peak_pvs": None,
|
|
"peak_pvs_at": None,
|
|
"peak_pvs_serial": None,
|
|
"last_event": None,
|
|
"false_trigger_count": 0,
|
|
},
|
|
"assignments_total": 0,
|
|
"device_type": unit.device_type,
|
|
}
|
|
|
|
from backend.services.sfm_events import events_for_unit
|
|
|
|
result = await events_for_unit(
|
|
db,
|
|
unit_id,
|
|
bucket=bucket,
|
|
from_dt=from_dt,
|
|
to_dt=to_dt,
|
|
false_trigger=false_trigger,
|
|
limit=limit,
|
|
)
|
|
result["device_type"] = unit.device_type
|
|
return result
|