From bc5a151faa8f743281cc555167821026e2c24324 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 11 May 2026 22:38:46 +0000 Subject: [PATCH] feat(sfm): per-unit event history with attribution + Unattributed bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "d before/after " 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 "d before/after " 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 --- backend/routers/units.py | 68 ++++++++- backend/services/sfm_events.py | 164 +++++++++++++++++++- templates/unit_detail.html | 263 +++++++++++++++++++++++++++++++++ 3 files changed, 492 insertions(+), 3 deletions(-) diff --git a/backend/routers/units.py b/backend/routers/units.py index 31ffc07..0b84cec 100644 --- a/backend/routers/units.py +++ b/backend/routers/units.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from datetime import datetime -from typing import Dict, Any +from typing import Dict, Any, Optional from backend.database import get_db from backend.services.snapshot import emit_status_snapshot @@ -72,3 +72,67 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)): "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 diff --git a/backend/services/sfm_events.py b/backend/services/sfm_events.py index 19312ec..08da595 100644 --- a/backend/services/sfm_events.py +++ b/backend/services/sfm_events.py @@ -29,7 +29,7 @@ from typing import Optional import httpx from sqlalchemy.orm import Session -from backend.models import UnitAssignment, RosterUnit +from backend.models import UnitAssignment, RosterUnit, MonitoringLocation, Project log = logging.getLogger("backend.services.sfm_events") @@ -252,6 +252,168 @@ async def events_for_location( } +# ── Per-unit (cross-project) view ───────────────────────────────────────────── + + +async def events_for_unit( + db: Session, + unit_id: str, + *, + bucket: str = "all", # "all" | "attributed" | "unattributed" + from_dt: Optional[datetime] = None, + to_dt: Optional[datetime] = None, + false_trigger: Optional[bool] = None, + limit: int = 500, +) -> dict: + """Return events for a unit annotated with their assignment attribution. + + Unlike events_for_location (which queries SFM per assignment window), this + helper queries SFM for ALL events for the serial within the optional + [from_dt, to_dt] filter, then walks each event against the unit's + UnitAssignment intervals to compute attribution. + + Bucket semantics: + - "all": every event, attributed or not + - "attributed": events that fall inside at least one assignment window + - "unattributed": events with no overlapping assignment (the diagnostic + bucket — operator should fix assignment dates to + attribute these) + + Each event gets an extra `attribution` field: + {assignment_id, location_id, location_name, project_id, project_name, + assigned_at, assigned_until} or None + + Unattributed events also get a `nearest_assignment` field with the + same shape plus `delta_days` (signed; negative = event before assignment). + """ + # 1. Pull all assignments for this unit (any device_type — caller has + # already filtered by seismograph in the route). Order matters: we + # want the earliest-start assignment first so attribution prefers the + # chronologically-first overlap when there are simultaneous active + # assignments at different locations (rare but possible). + assignments = ( + db.query(UnitAssignment) + .filter(UnitAssignment.unit_id == unit_id) + .order_by(UnitAssignment.assigned_at.asc()) + .all() + ) + + # Resolve location + project names once. + loc_ids = {a.location_id for a in assignments} + proj_ids = {a.project_id for a in assignments} + loc_map = { + l.id: l for l in db.query(MonitoringLocation).filter( + MonitoringLocation.id.in_(loc_ids) + ).all() + } if loc_ids else {} + proj_map = { + p.id: p for p in db.query(Project).filter( + Project.id.in_(proj_ids) + ).all() + } if proj_ids else {} + + now = datetime.utcnow() + + def _attr_dict(a: UnitAssignment) -> dict: + loc = loc_map.get(a.location_id) + proj = proj_map.get(a.project_id) + return { + "assignment_id": a.id, + "location_id": a.location_id, + "location_name": loc.name if loc else None, + "project_id": a.project_id, + "project_name": proj.name if proj else None, + "assigned_at": _iso_utc(a.assigned_at), + "assigned_until": _iso_utc(a.assigned_until), + } + + # 2. Fetch all events for this serial in one shot. + async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client: + events = await _fetch_events_for_serial( + client, + serial=unit_id, + from_dt=from_dt or datetime(1970, 1, 1), + to_dt=to_dt or now, + false_trigger=false_trigger, + limit=_SFM_FETCH_CEILING, + ) + + # 3. For each event, walk the assignment list and find the first + # overlapping window. O(N * M) but both are small in practice. + for ev in events: + ts_str = ev.get("timestamp") + if not ts_str: + ev["attribution"] = None + continue + try: + # SFM returns ISO with "T" separator; tolerate both. + ts = datetime.fromisoformat(ts_str.replace(" ", "T")) + except ValueError: + ev["attribution"] = None + continue + + matched: Optional[UnitAssignment] = None + for a in assignments: + a_end = a.assigned_until or now + if a.assigned_at <= ts <= a_end: + matched = a + break + + if matched is not None: + ev["attribution"] = _attr_dict(matched) + else: + ev["attribution"] = None + # Find the nearest assignment (chronologically) for diagnostic. + if assignments: + nearest = min( + assignments, + key=lambda a: min( + abs((ts - a.assigned_at).total_seconds()), + abs((ts - (a.assigned_until or now)).total_seconds()), + ), + ) + # Signed delta in days from the nearest boundary + # (negative = event BEFORE that boundary). + if ts < nearest.assigned_at: + delta_seconds = (ts - nearest.assigned_at).total_seconds() + elif ts > (nearest.assigned_until or now): + delta_seconds = (ts - (nearest.assigned_until or now)).total_seconds() + else: + delta_seconds = 0 + ev["nearest_assignment"] = { + **_attr_dict(nearest), + "delta_days": round(delta_seconds / 86400, 1), + } + + # 4. Apply bucket filter. + if bucket == "attributed": + filtered = [e for e in events if e.get("attribution") is not None] + elif bucket == "unattributed": + filtered = [e for e in events if e.get("attribution") is None] + else: + filtered = events + + filtered.sort(key=lambda e: e.get("timestamp") or "", reverse=True) + total_count = len(filtered) + capped = filtered[:limit] + + # 5. Stats: compute over the ENTIRE event set (not the filtered bucket) + # so the unattributed_count tile is always meaningful regardless of + # which bucket the operator has selected. + base_stats = _compute_stats(events) + unattributed_count = sum( + 1 for e in events if e.get("attribution") is None + ) + base_stats["unattributed_count"] = unattributed_count + + return { + "events": capped, + "count": total_count, + "stats": base_stats, + "assignments_total": len(assignments), + } + + # ── Stats helpers ───────────────────────────────────────────────────────────── diff --git a/templates/unit_detail.html b/templates/unit_detail.html index d2fbf73..d33d8dd 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -294,6 +294,91 @@ + + +

Timeline

@@ -1886,8 +1971,186 @@ loadUnitData().then(() => { loadPhotos(); loadUnitHistory(); loadDeploymentHistory(); + if (currentUnit && currentUnit.device_type === 'seismograph') { + document.getElementById('sfmEventsSection').classList.remove('hidden'); + loadUnitEvents(); + } }); +// ── SFM Events section ────────────────────────────────────────────────────── +function clearUnitEventFilters() { + document.getElementById('ue-filter-bucket').value = 'all'; + document.getElementById('ue-filter-from').value = ''; + document.getElementById('ue-filter-to').value = ''; + document.getElementById('ue-filter-ft').value = ''; + document.getElementById('ue-filter-limit').value = '500'; + loadUnitEvents(); +} + +async function loadUnitEvents() { + if (!currentUnit || currentUnit.device_type !== 'seismograph') return; + const container = document.getElementById('ue-events-container'); + container.innerHTML = '
Loading events…
'; + + const params = new URLSearchParams(); + const bucket = document.getElementById('ue-filter-bucket').value; + const from = document.getElementById('ue-filter-from').value; + const to = document.getElementById('ue-filter-to').value; + const ft = document.getElementById('ue-filter-ft').value; + const limit = document.getElementById('ue-filter-limit').value; + params.set('bucket', bucket); + 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/units/${currentUnit.id}/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(); + renderUnitEventStats(d.stats); + renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total); + } catch (e) { + container.innerHTML = `
Failed to load events: ${e.message}
`; + } +} + +function renderUnitEventStats(stats) { + const s = stats || {}; + document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString(); + const unattrEl = document.getElementById('ue-stat-unattr'); + unattrEl.textContent = (s.unattributed_count ?? 0).toLocaleString(); + // Highlight unattributed in amber/red if non-zero — visual signal that + // the operator has assignment-window cleanup to do. + unattrEl.className = 'text-2xl font-bold mt-1 ' + ( + (s.unattributed_count ?? 0) > 0 + ? 'text-amber-600 dark:text-amber-400' + : 'text-gray-900 dark:text-white' + ); + + if (s.peak_pvs != null) { + document.getElementById('ue-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s'; + const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : ''; + document.getElementById('ue-stat-peak-when').textContent = when || '—'; + } else { + document.getElementById('ue-stat-peak').textContent = '—'; + document.getElementById('ue-stat-peak-when').textContent = '—'; + } + + if (s.last_event) { + const dt = s.last_event.slice(0, 19).replace('T', ' '); + document.getElementById('ue-stat-last').textContent = dt; + } else { + document.getElementById('ue-stat-last').textContent = '—'; + } +} + +function _ueFmtPPV(v) { + if (v == null) return '—'; + return v.toFixed(4); +} + +function _uePpvClass(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 _ueEsc(s) { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function _ueAttrCell(ev) { + const a = ev.attribution; + if (a) { + // Attributed: project / location link. + const projLabel = _ueEsc(a.project_name || '—'); + const locLabel = _ueEsc(a.location_name || '—'); + return ` + 📍 ${locLabel} + +
${projLabel}
`; + } + // Unattributed: show nearest assignment + delta for context. + const n = ev.nearest_assignment; + if (n) { + const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary'); + const days = Math.abs(n.delta_days); + const daysLabel = days < 1 + ? `<${(days * 24).toFixed(1)}h` + : `${days.toFixed(1)}d`; + return `⚠ Unattributed +
+ ${daysLabel} ${_ueEsc(sign)} ${_ueEsc(n.location_name || '?')} +
`; + } + return `⚠ No assignments`; +} + +function renderUnitEventTable(events, total, container, bucket, assignmentsTotal) { + if (!events || events.length === 0) { + let msg; + if (bucket === 'unattributed') { + msg = assignmentsTotal === 0 + ? 'No assignments yet — every event from this unit is unattributed. Assign it to a project location to start attributing events.' + : '✅ All events for this unit are attributed to a project/location.'; + } else if (bucket === 'attributed') { + msg = assignmentsTotal === 0 + ? 'No assignments yet for this unit.' + : 'No events recorded inside any assignment window with the current filter.'; + } else { + msg = 'No events found for this unit with the current filter.'; + } + container.innerHTML = `
${msg}
`; + return; + } + + const rows = events.map(ev => { + const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; + const tran = _ueFmtPPV(ev.tran_ppv); + const vert = _ueFmtPPV(ev.vert_ppv); + const lng = _ueFmtPPV(ev.long_ppv); + const pvs = _ueFmtPPV(ev.peak_vector_sum); + const ft = ev.false_trigger + ? 'FT' + : ''; + + return ` + ${ts} + ${tran} + ${vert} + ${lng} + ${pvs} + ${ft} + ${_ueAttrCell(ev)} + `; + }).join(''); + + container.innerHTML = ` +
Showing ${events.length} of ${total.toLocaleString()} event${total === 1 ? '' : 's'}
+ + + + + + + + + + + + + ${rows} +
TimestampTranVertLongPVSFlagsAttribution
`; +} + // ===== Pair Device Modal Functions ===== let pairModalModems = []; // Cache loaded modems let pairModalDeviceType = ''; // Current device type