feat(sfm): per-unit event history with attribution + Unattributed bucket
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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
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,
|
"slm_serial_number": unit.slm_serial_number,
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id
|
"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
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy.orm import Session
|
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")
|
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 ─────────────────────────────────────────────────────────────
|
# ── Stats helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,91 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SFM Events (seismographs only) -->
|
||||||
|
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">SFM Events</h3>
|
||||||
|
<button onclick="loadUnitEvents()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI tiles -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||||
|
<span id="ue-stat-total" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Unattributed</span>
|
||||||
|
<span id="ue-stat-unattr" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">outside any assignment window</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
|
||||||
|
<span id="ue-stat-peak" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
<span id="ue-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||||
|
<span id="ue-stat-last" class="text-base font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Bucket</label>
|
||||||
|
<select id="ue-filter-bucket" onchange="loadUnitEvents()"
|
||||||
|
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">All Events</option>
|
||||||
|
<option value="attributed">Attributed Only</option>
|
||||||
|
<option value="unattributed">Unattributed Only</option>
|
||||||
|
</select>
|
||||||
|
</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="ue-filter-from" onchange="loadUnitEvents()"
|
||||||
|
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="ue-filter-to" onchange="loadUnitEvents()"
|
||||||
|
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="ue-filter-ft" onchange="loadUnitEvents()"
|
||||||
|
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="ue-filter-limit" onchange="loadUnitEvents()"
|
||||||
|
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="clearUnitEventFilters()"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Event table -->
|
||||||
|
<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">
|
||||||
|
Loading events…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Unit History Timeline -->
|
<!-- Unit History Timeline -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
||||||
@@ -1886,8 +1971,186 @@ loadUnitData().then(() => {
|
|||||||
loadPhotos();
|
loadPhotos();
|
||||||
loadUnitHistory();
|
loadUnitHistory();
|
||||||
loadDeploymentHistory();
|
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 = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>';
|
||||||
|
|
||||||
|
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 = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '>').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 `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
||||||
|
class="text-seismo-orange hover:text-seismo-navy"
|
||||||
|
title="${projLabel} → ${locLabel}">
|
||||||
|
📍 ${locLabel}
|
||||||
|
</a>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||||
|
}
|
||||||
|
// 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 `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `<span class="px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">⚠ No assignments</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<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 = _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
|
||||||
|
? '<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 ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}">
|
||||||
|
<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.vert_ppv)}">${vert}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.long_ppv)}">${lng}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_uePpvClass(ev.peak_vector_sum)}">${pvs}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm">${ft}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm">${_ueAttrCell(ev)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3 pb-1">Showing ${events.length} of ${total.toLocaleString()} event${total === 1 ? '' : 's'}</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">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">Flags</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Pair Device Modal Functions =====
|
// ===== Pair Device Modal Functions =====
|
||||||
let pairModalModems = []; // Cache loaded modems
|
let pairModalModems = []; // Cache loaded modems
|
||||||
let pairModalDeviceType = ''; // Current device type
|
let pairModalDeviceType = ''; // Current device type
|
||||||
|
|||||||
Reference in New Issue
Block a user