diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 1074a5a..376b202 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -160,6 +160,7 @@ async def get_project_locations( # (sessions don't really exist for the watcher-forward pipeline). # Sound locations skip this and keep showing session counts. event_counts: dict[str, int] = {} + last_events: dict[str, str] = {} vibration_locations = [l for l in locations if l.location_type == "vibration"] if vibration_locations: import asyncio @@ -171,7 +172,10 @@ async def get_project_locations( for loc, res in zip(vibration_locations, results): if isinstance(res, Exception): continue # leave event_counts[loc.id] unset → template falls back - event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0 + stats = res.get("stats") or {} + event_counts[loc.id] = stats.get("event_count", 0) or 0 + if stats.get("last_event"): + last_events[loc.id] = stats["last_event"] # Enrich with assignment info, splitting active vs removed. active_data: list = [] @@ -205,6 +209,8 @@ async def get_project_locations( } if location.id in event_counts: item["event_count"] = event_counts[location.id] + if location.id in last_events: + item["last_event"] = last_events[location.id] if location.removed_at is None: active_data.append(item) else: @@ -1579,6 +1585,70 @@ async def get_project_vibration_summary( ) +@router.get("/vibration-events", response_class=JSONResponse) +async def get_project_vibration_events( + project_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), +): + """Project-wide SFM events across every active vibration location. + + Fans out events_for_location per location (each of which unions that + location's assignment windows), tags each event with its location, then + merges newest-first. Powers the Vibration tab's Events sub-tab. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found.") + + locations = ( + db.query(MonitoringLocation) + .filter( + MonitoringLocation.project_id == project_id, + MonitoringLocation.location_type == "vibration", + MonitoringLocation.removed_at.is_(None), + ) + .all() + ) + if not locations: + return {"events": [], "count": 0, "location_count": 0} + + import asyncio + from backend.services.sfm_events import events_for_location + + results = await asyncio.gather( + *( + events_for_location( + db, loc.id, from_dt=from_dt, to_dt=to_dt, + false_trigger=false_trigger, limit=limit, + ) + for loc in locations + ), + return_exceptions=True, + ) + + merged = [] + for loc, res in zip(locations, results): + if isinstance(res, Exception): + continue + for ev in res.get("events", []): + ev = dict(ev) + ev["location_id"] = loc.id + ev["location_name"] = loc.name + merged.append(ev) + + merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True) + total = len(merged) + return { + "events": merged[:limit], + "count": total, + "location_count": len(locations), + } + + @router.get("/locations/{location_id}/events", response_class=JSONResponse) async def get_location_events( project_id: str, diff --git a/templates/partials/projects/location_list.html b/templates/partials/projects/location_list.html index 8dfb08e..d44921c 100644 --- a/templates/partials/projects/location_list.html +++ b/templates/partials/projects/location_list.html @@ -74,6 +74,9 @@ {% else %} No active assignment {% endif %} + {% if item.last_event %} + Last event: {{ item.last_event[:16] }} + {% endif %} diff --git a/templates/projects/detail.html b/templates/projects/detail.html index c37f190..7093a51 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -137,7 +137,10 @@ class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap"> Locations - + @@ -183,6 +186,50 @@ + + +
@@ -985,6 +1032,98 @@ function switchVibSubTab(name) { btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400'); btn.classList.add('border-seismo-orange', 'text-seismo-orange'); } + // Lazy-load the Events table on first open. + if (name === 'events' && !_projectEventsLoaded) { + _projectEventsLoaded = true; + loadProjectVibrationEvents(); + } +} + +// ── Vibration Events sub-tab ───────────────────────────────────────────── +let _projectEventsLoaded = false; + +function clearProjectEventFilters() { + document.getElementById('pve-from').value = ''; + document.getElementById('pve-to').value = ''; + document.getElementById('pve-ft').value = ''; + loadProjectVibrationEvents(); +} + +function _pveFmtPPV(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); } +function _pvePPVClass(v) { + if (v == null) return 'text-gray-400'; + if (v >= 0.5) return 'text-red-500'; + if (v >= 0.2) return 'text-amber-500'; + return 'text-green-600 dark:text-green-400'; +} + +async function loadProjectVibrationEvents() { + const container = document.getElementById('pve-container'); + if (!container) return; + container.innerHTML = '| Timestamp | +Location | +Serial | +Tran | +Vert | +Long | +PVS | +Mic | +Flags | +
|---|