feat: Vibration Events sub-tab + last-event on location cards
Two additions to the project Vibration tab:
- Events sub-tab (next to Locations): a project-wide events table across all
vibration locations. New GET /api/projects/{id}/vibration-events fans
events_for_location across the project's vibration locations, tags each event
with its location, and merges newest-first (From/To date filters, Real/FT
filter, limit). Table columns Timestamp/Location/Serial/Tran/Vert/Long/PVS/
Mic/Flags; rows open the shared event-detail modal (Chart.js + event-modal.js
come from the modal partial). Lazy-loads on first open; refreshes on
sfm-event-review-saved.
- Last event per location card: thread last_event (already in
events_for_location stats) through the locations endpoint and show
"Last event: …" on vibration cards.
Reuses the same event source + modal as the per-location Events tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user