Compare commits
5 Commits
a71e6f5efd
...
f1f3da8e61
| Author | SHA1 | Date | |
|---|---|---|---|
| f1f3da8e61 | |||
| 63bd6ad8a2 | |||
| bc5a151faa | |||
| 09db988a35 | |||
| df771a87de |
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Migration: deprecate the `deployment_records` table.
|
||||
|
||||
Why:
|
||||
The deployment-history view on the unit detail page used to render
|
||||
from `deployment_records` — a manually-maintained table that drifted
|
||||
out of sync with `unit_assignments` (the auto-written project/location
|
||||
assignment table). That caused the "wonky timeline" symptom: missing
|
||||
entries, duplicate / contradictory rows, and a UI that couldn't tell
|
||||
the operator what the unit was actually doing during each window.
|
||||
|
||||
Phase 4 of the SFM integration replaces the deployment-history view
|
||||
with a derived timeline computed from `unit_assignments` +
|
||||
`unit_history` + SFM event overlay. This migration is the cleanup:
|
||||
|
||||
1. Adds a `deprecated_at` timestamp column to `deployment_records` so
|
||||
we can mark rows that have been migrated.
|
||||
2. For every `deployment_records` row that does NOT have a matching
|
||||
`unit_assignments` row (matched by unit_id + overlapping date
|
||||
range), synthesizes a best-effort UnitAssignment row. The
|
||||
free-text `location_name` from the legacy table is preserved on
|
||||
the new row's `notes` field (we do NOT try to fuzzy-match it to a
|
||||
MonitoringLocation id; too error-prone — operators will need to
|
||||
reattach those manually if they want).
|
||||
3. Marks every migrated deployment_records row with `deprecated_at`.
|
||||
|
||||
This migration is non-destructive: deployment_records rows stay in
|
||||
the DB. The actual `DROP TABLE` happens in a follow-up release after
|
||||
one operator cycle confirms nothing relies on the legacy data.
|
||||
|
||||
Idempotent: re-running the script is a no-op if the column already
|
||||
exists and all migratable rows have already been processed.
|
||||
|
||||
Run with:
|
||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_deprecate_deployment_records.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate_database():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# 1. Add deprecated_at column if not present.
|
||||
cur.execute("PRAGMA table_info(deployment_records)")
|
||||
cols = {row["name"] for row in cur.fetchall()}
|
||||
if "deprecated_at" not in cols:
|
||||
print("Adding deployment_records.deprecated_at column ...")
|
||||
cur.execute("ALTER TABLE deployment_records ADD COLUMN deprecated_at TEXT")
|
||||
conn.commit()
|
||||
else:
|
||||
print("deployment_records.deprecated_at column already exists — skipping ADD COLUMN")
|
||||
|
||||
# 2. Find candidate rows: not-yet-deprecated deployment_records that
|
||||
# have no matching unit_assignments row.
|
||||
cur.execute("""
|
||||
SELECT id, unit_id, deployed_date, estimated_removal_date,
|
||||
actual_removal_date, project_id, project_ref, location_name, notes
|
||||
FROM deployment_records
|
||||
WHERE deprecated_at IS NULL
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
print(f"\nFound {len(rows)} deployment_records rows not yet deprecated.")
|
||||
|
||||
backfilled = 0
|
||||
skipped_no_match_attempted = 0
|
||||
skipped_already_in_assignments = 0
|
||||
skipped_missing_unit = 0
|
||||
|
||||
for row in rows:
|
||||
unit_id = row["unit_id"]
|
||||
if not unit_id:
|
||||
print(f" ⚠ row {row['id']!r}: no unit_id, marking deprecated without backfill")
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_missing_unit += 1
|
||||
continue
|
||||
|
||||
# Does the unit still exist? If not, skip — we don't synthesize
|
||||
# assignments for ghost units.
|
||||
cur.execute("SELECT id, device_type FROM roster WHERE id=?", (unit_id,))
|
||||
roster = cur.fetchone()
|
||||
if not roster:
|
||||
print(f" ⚠ row {row['id']!r}: unit_id {unit_id!r} not in roster, marking deprecated without backfill")
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_missing_unit += 1
|
||||
continue
|
||||
|
||||
# Check if a UnitAssignment already covers this window (any overlap).
|
||||
# We don't try to be clever — just see if a row exists for this unit
|
||||
# whose [assigned_at, assigned_until] overlaps the deployment window.
|
||||
cur.execute("""
|
||||
SELECT id FROM unit_assignments
|
||||
WHERE unit_id=?
|
||||
AND (assigned_at <= COALESCE(?, '9999')
|
||||
AND COALESCE(assigned_until, '9999') >= COALESCE(?, '0000'))
|
||||
LIMIT 1
|
||||
""", (
|
||||
unit_id,
|
||||
row["actual_removal_date"] or row["estimated_removal_date"] or row["deployed_date"],
|
||||
row["deployed_date"],
|
||||
))
|
||||
if cur.fetchone():
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_already_in_assignments += 1
|
||||
continue
|
||||
|
||||
# No matching UnitAssignment — synthesize one. We can't FK to a
|
||||
# MonitoringLocation because the legacy `location_name` is free
|
||||
# text. Backfilled rows go in with location_id = "" (empty) and
|
||||
# the original location_name dropped into notes for operator
|
||||
# context.
|
||||
if not row["project_id"]:
|
||||
print(f" ⚠ row {row['id']!r}: no project_id, can't synthesize unit_assignment, marking deprecated")
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_no_match_attempted += 1
|
||||
continue
|
||||
|
||||
synthesized_id = str(uuid.uuid4())
|
||||
synth_notes_parts = []
|
||||
if row["location_name"]:
|
||||
synth_notes_parts.append(f"Legacy location: {row['location_name']}")
|
||||
if row["project_ref"]:
|
||||
synth_notes_parts.append(f"Legacy project_ref: {row['project_ref']}")
|
||||
if row["notes"]:
|
||||
synth_notes_parts.append(f"Original notes: {row['notes']}")
|
||||
synth_notes_parts.append(f"(Synthesized from deployment_records row {row['id']})")
|
||||
synth_notes = " | ".join(synth_notes_parts)
|
||||
|
||||
assigned_until = row["actual_removal_date"]
|
||||
# Don't auto-close active deployments based on estimated_removal_date.
|
||||
status = "completed" if assigned_until else "active"
|
||||
|
||||
# Need a location_id to satisfy NOT NULL constraint. Use a
|
||||
# placeholder UUID so the FK can be cleaned up later if the
|
||||
# operator decides to retarget the assignment to a real location.
|
||||
# We tag this with the synthesized notes so it's discoverable.
|
||||
placeholder_loc_id = ""
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO unit_assignments (
|
||||
id, unit_id, location_id, project_id, device_type,
|
||||
assigned_at, assigned_until, status, notes, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
synthesized_id,
|
||||
unit_id,
|
||||
placeholder_loc_id,
|
||||
row["project_id"],
|
||||
roster["device_type"] or "seismograph",
|
||||
row["deployed_date"] or datetime.utcnow().isoformat(),
|
||||
assigned_until,
|
||||
status,
|
||||
synth_notes,
|
||||
datetime.utcnow().isoformat(),
|
||||
))
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
backfilled += 1
|
||||
print(
|
||||
f" ✓ row {row['id']!r}: synthesized unit_assignment {synthesized_id} "
|
||||
f"for unit={unit_id} project={row['project_id'][:8]}… "
|
||||
f"({row['deployed_date']} → {assigned_until or 'present'})"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ✗ row {row['id']!r}: failed to synthesize — {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("\n────────────────────────────────────────────────────────")
|
||||
print(f"Backfilled new unit_assignments: {backfilled}")
|
||||
print(f"Already covered (deprecated only): {skipped_already_in_assignments}")
|
||||
print(f"No project_id (deprecated only): {skipped_no_match_attempted}")
|
||||
print(f"Missing/orphaned unit (deprecated): {skipped_missing_unit}")
|
||||
print(f"\nNOTE: synthesized rows have an empty location_id and the legacy")
|
||||
print(f" free-text location is preserved in notes. An operator should")
|
||||
print(f" retarget them to real MonitoringLocation rows if they want")
|
||||
print(f" events to show up on a location detail page.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
@@ -30,6 +30,7 @@ from backend.models import (
|
||||
RosterUnit,
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
UnitHistory,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.utils.timezone import local_to_utc
|
||||
@@ -37,6 +38,42 @@ from backend.utils.timezone import local_to_utc
|
||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||
|
||||
|
||||
# ── Audit log helper ──────────────────────────────────────────────────────────
|
||||
# Mirrors record_history() in roster_edit.py. Kept local to avoid cross-router
|
||||
# imports. The four assignment endpoints below use this to write UnitHistory
|
||||
# rows that the unit-detail deployment timeline (Phase 4) renders.
|
||||
|
||||
def _record_assignment_history(
|
||||
db: Session,
|
||||
unit_id: str,
|
||||
change_type: str,
|
||||
*,
|
||||
old_value: Optional[str] = None,
|
||||
new_value: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Append a UnitHistory row for an assignment-lifecycle event.
|
||||
|
||||
change_type values used:
|
||||
- assignment_created — unit assigned to a location (new assignment)
|
||||
- assignment_ended — unit unassigned / removed (assigned_until set)
|
||||
- assignment_swapped — unit replaced by another at the same location
|
||||
- assignment_updated — assignment dates / notes edited via PATCH
|
||||
|
||||
Caller is responsible for db.commit().
|
||||
"""
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type=change_type,
|
||||
field_name="unit_assignment",
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_at=datetime.utcnow(),
|
||||
source="manual",
|
||||
notes=notes,
|
||||
))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shared helpers
|
||||
# ============================================================================
|
||||
@@ -403,6 +440,13 @@ async def assign_unit_to_location(
|
||||
)
|
||||
|
||||
db.add(assignment)
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=unit_id,
|
||||
change_type="assignment_created",
|
||||
new_value=f"{location.name} (project: {location.project_id})",
|
||||
notes=form_data.get("notes"),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
@@ -448,11 +492,164 @@ async def unassign_unit(
|
||||
assignment.status = "completed"
|
||||
assignment.assigned_until = datetime.utcnow()
|
||||
|
||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=assignment.unit_id,
|
||||
change_type="assignment_ended",
|
||||
old_value=location.name if location else assignment.location_id,
|
||||
new_value="unassigned",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Unit unassigned successfully"}
|
||||
|
||||
|
||||
@router.patch("/assignments/{assignment_id}")
|
||||
async def update_assignment(
|
||||
project_id: str,
|
||||
assignment_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update an assignment's date window and/or notes.
|
||||
|
||||
Common use case: backdate a deployment so events emitted before the
|
||||
operator created the assignment in terra-view (e.g. a unit that was
|
||||
physically deployed in December but only recorded in the system today)
|
||||
get correctly attributed to the location.
|
||||
|
||||
Accepts JSON body with optional fields:
|
||||
- assigned_at: ISO datetime (or empty string to leave unchanged)
|
||||
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
||||
- notes: string
|
||||
|
||||
Sets `status` to "active" when assigned_until is cleared, "completed"
|
||||
when it's set in the past.
|
||||
"""
|
||||
assignment = db.query(UnitAssignment).filter_by(
|
||||
id=assignment_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
# Parse new values (None = unchanged, explicit None/"" for assigned_until = clear)
|
||||
new_assigned_at = assignment.assigned_at
|
||||
new_assigned_until = assignment.assigned_until
|
||||
new_notes = assignment.notes
|
||||
|
||||
if "assigned_at" in payload:
|
||||
raw = payload["assigned_at"]
|
||||
if raw is None or raw == "":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="assigned_at is required; cannot be cleared.",
|
||||
)
|
||||
try:
|
||||
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
||||
new_assigned_at = datetime.fromisoformat(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid assigned_at datetime: {raw!r}",
|
||||
)
|
||||
|
||||
if "assigned_until" in payload:
|
||||
raw = payload["assigned_until"]
|
||||
if raw is None or raw == "":
|
||||
new_assigned_until = None
|
||||
else:
|
||||
try:
|
||||
new_assigned_until = datetime.fromisoformat(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid assigned_until datetime: {raw!r}",
|
||||
)
|
||||
|
||||
if "notes" in payload:
|
||||
raw = payload["notes"]
|
||||
new_notes = (raw or "").strip() or None
|
||||
|
||||
# Validation: end must be after start if both set.
|
||||
if new_assigned_until is not None and new_assigned_until <= new_assigned_at:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="assigned_until must be after assigned_at.",
|
||||
)
|
||||
|
||||
# Sanity: reject creating an overlap with another assignment of the SAME
|
||||
# unit at the SAME location. Different units at the same location can
|
||||
# legitimately overlap during a swap window (rare but valid).
|
||||
new_end_for_overlap = new_assigned_until or datetime.utcnow()
|
||||
overlapping = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == assignment.location_id)
|
||||
.filter(UnitAssignment.unit_id == assignment.unit_id)
|
||||
.filter(UnitAssignment.id != assignment.id)
|
||||
.all()
|
||||
)
|
||||
for other in overlapping:
|
||||
other_start = other.assigned_at
|
||||
other_end = other.assigned_until or datetime.utcnow()
|
||||
if new_assigned_at < other_end and new_end_for_overlap > other_start:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"This window overlaps with another assignment for the "
|
||||
f"same unit ({other.assigned_at:%Y-%m-%d} → "
|
||||
f"{other.assigned_until and other.assigned_until.strftime('%Y-%m-%d') or 'present'})."
|
||||
),
|
||||
)
|
||||
|
||||
# Capture change description for audit log BEFORE mutating.
|
||||
old_start = assignment.assigned_at.isoformat() if assignment.assigned_at else None
|
||||
old_end = assignment.assigned_until.isoformat() if assignment.assigned_until else "active"
|
||||
new_start = new_assigned_at.isoformat() if new_assigned_at else None
|
||||
new_end = new_assigned_until.isoformat() if new_assigned_until else "active"
|
||||
|
||||
# Apply.
|
||||
assignment.assigned_at = new_assigned_at
|
||||
assignment.assigned_until = new_assigned_until
|
||||
assignment.notes = new_notes
|
||||
assignment.status = "completed" if new_assigned_until is not None else "active"
|
||||
|
||||
if old_start != new_start or old_end != new_end:
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=assignment.unit_id,
|
||||
change_type="assignment_updated",
|
||||
old_value=f"{old_start} → {old_end}",
|
||||
new_value=f"{new_start} → {new_end}",
|
||||
notes=new_notes,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"assignment": {
|
||||
"id": assignment.id,
|
||||
"unit_id": assignment.unit_id,
|
||||
"location_id": assignment.location_id,
|
||||
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
|
||||
"assigned_until": assignment.assigned_until.isoformat() if assignment.assigned_until else None,
|
||||
"status": assignment.status,
|
||||
"notes": assignment.notes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/locations/{location_id}/swap")
|
||||
async def swap_unit_on_location(
|
||||
project_id: str,
|
||||
@@ -503,6 +700,16 @@ async def swap_unit_on_location(
|
||||
if current:
|
||||
current.assigned_until = datetime.utcnow()
|
||||
current.status = "completed"
|
||||
# If the swap is replacing a different unit, that unit's deployment ended.
|
||||
if current.unit_id != unit_id:
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=current.unit_id,
|
||||
change_type="assignment_swapped",
|
||||
old_value=location.name,
|
||||
new_value=f"swapped out → {unit_id}",
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Create new assignment
|
||||
new_assignment = UnitAssignment(
|
||||
@@ -516,6 +723,13 @@ async def swap_unit_on_location(
|
||||
notes=notes,
|
||||
)
|
||||
db.add(new_assignment)
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=unit_id,
|
||||
change_type="assignment_swapped" if (current and current.unit_id != unit_id) else "assignment_created",
|
||||
new_value=f"{location.name} (project: {location.project_id})",
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Update modem pairing on the seismograph if modem provided
|
||||
if modem_id:
|
||||
@@ -648,6 +862,108 @@ async def get_nrl_sessions(
|
||||
})
|
||||
|
||||
|
||||
@router.get("/vibration_summary", response_class=HTMLResponse)
|
||||
async def get_project_vibration_summary(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
from_dt: Optional[datetime] = Query(None),
|
||||
to_dt: Optional[datetime] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render a small HTML partial summarising vibration-event activity
|
||||
across every vibration MonitoringLocation in the project.
|
||||
|
||||
Returned to the Vibration tab of the project detail page via HTMX.
|
||||
Fans out concurrently across all locations (which in turn fan out
|
||||
across each location's UnitAssignment windows). Total queries to
|
||||
SFM = sum of assignments across the project.
|
||||
|
||||
404 if the project doesn't exist. Empty-state partial if the
|
||||
project has no vibration locations.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found.")
|
||||
|
||||
from backend.services.sfm_events import vibration_summary_for_project
|
||||
|
||||
summary = await vibration_summary_for_project(
|
||||
db, project_id, from_dt=from_dt, to_dt=to_dt
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/projects/vibration_summary.html",
|
||||
{
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
|
||||
async def get_location_events(
|
||||
project_id: str,
|
||||
location_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),
|
||||
):
|
||||
"""
|
||||
Return SFM events recorded at this monitoring location.
|
||||
|
||||
Fans out the location's UnitAssignment rows (every seismograph ever
|
||||
assigned to this location, active + closed), queries SFM /db/events
|
||||
for each (serial, time-window) pair concurrently, and unions the
|
||||
results.
|
||||
|
||||
Sound (SLM) locations return an empty payload — SFM events are
|
||||
seismograph-only.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found.")
|
||||
if location.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Location does not belong to this project.",
|
||||
)
|
||||
|
||||
# SLM locations don't have SFM events — return an empty payload rather
|
||||
# than 404 so the frontend can render an empty state gracefully.
|
||||
if location.location_type != "vibration":
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"event_count": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_serial": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
},
|
||||
"assignments_used": [],
|
||||
"location_type": location.location_type,
|
||||
}
|
||||
|
||||
from backend.services.sfm_events import events_for_location
|
||||
|
||||
result = await events_for_location(
|
||||
db,
|
||||
location_id,
|
||||
from_dt=from_dt,
|
||||
to_dt=to_dt,
|
||||
false_trigger=false_trigger,
|
||||
limit=limit,
|
||||
)
|
||||
result["location_type"] = location.location_type
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
|
||||
async def get_nrl_files(
|
||||
project_id: str,
|
||||
|
||||
+100
-2
@@ -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,101 @@ 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
|
||||
|
||||
|
||||
@router.get("/units/{unit_id}/deployment_timeline")
|
||||
async def get_unit_deployment_timeline(
|
||||
unit_id: str,
|
||||
include_events: bool = Query(True),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Return a chronological deployment timeline for a unit.
|
||||
|
||||
Merges three sources:
|
||||
1. unit_assignments — authoritative project/location deployments
|
||||
2. unit_history — state changes (calibration, retirement, etc.)
|
||||
3. SFM events — per-assignment overlay (count + peak PVS + last event)
|
||||
|
||||
Replaces the legacy /api/deployments/{unit_id} (which read the
|
||||
deprecated `deployment_records` table) and the
|
||||
/api/roster/history/{unit_id} timeline endpoint, unifying them into
|
||||
a single derived view.
|
||||
|
||||
Gaps >= 1 day between consecutive assignments are surfaced as
|
||||
synthetic "gap" entries.
|
||||
|
||||
Pass include_events=false to skip the SFM event overlay (saves N
|
||||
HTTP calls; useful for fast text-only history dumps).
|
||||
"""
|
||||
from backend.services.deployment_timeline import deployment_timeline_for_unit
|
||||
|
||||
return await deployment_timeline_for_unit(
|
||||
db,
|
||||
unit_id,
|
||||
include_event_overlay=include_events,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Deployment timeline service — replaces the legacy `deployment_records`-driven
|
||||
timeline on the seismograph unit detail page.
|
||||
|
||||
Architecture:
|
||||
- `unit_assignments` is the authoritative source for "where was this unit"
|
||||
(one row per location/time-window). Auto-written by the project location
|
||||
swap/assign/unassign/update workflows.
|
||||
- `unit_history` is the audit log for non-location state changes
|
||||
(calibration toggles, retirement, allocation, etc.).
|
||||
- SFM events are overlaid per assignment window to show "what was the unit
|
||||
actually doing during this deployment" (count + peak PVS + last-event).
|
||||
|
||||
Gaps between assignments are emitted as synthetic "gap" entries so operators
|
||||
can see when the unit was idle vs out-of-service.
|
||||
|
||||
`deployment_records` is being deprecated; this module does not read it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import (
|
||||
UnitAssignment,
|
||||
UnitHistory,
|
||||
MonitoringLocation,
|
||||
Project,
|
||||
RosterUnit,
|
||||
)
|
||||
from backend.services.sfm_events import (
|
||||
SFM_BASE_URL,
|
||||
_fetch_events_for_serial,
|
||||
_iso_utc,
|
||||
)
|
||||
|
||||
log = logging.getLogger("backend.services.deployment_timeline")
|
||||
|
||||
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
||||
# clutter from a sub-second handoff during a swap workflow.
|
||||
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
||||
|
||||
# Per-call timeout when querying SFM for the event overlay.
|
||||
_SFM_TIMEOUT = 10.0
|
||||
_SFM_FETCH_CEILING = 5000
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def deployment_timeline_for_unit(
|
||||
db: Session,
|
||||
unit_id: str,
|
||||
*,
|
||||
include_event_overlay: bool = True,
|
||||
) -> dict:
|
||||
"""Build a chronological timeline for a unit.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"unit_id": str,
|
||||
"device_type": str,
|
||||
"entries": [
|
||||
{
|
||||
"kind": "assignment" | "gap" | "state_change",
|
||||
"starts_at": ISO timestamp,
|
||||
"ends_at": ISO timestamp | None,
|
||||
"duration_days": float | None,
|
||||
# — assignment-only fields —
|
||||
"assignment_id": str,
|
||||
"location_id": str,
|
||||
"location_name": str,
|
||||
"project_id": str,
|
||||
"project_name": str,
|
||||
"is_active": bool,
|
||||
"event_overlay": {event_count, peak_pvs, peak_pvs_at, last_event}
|
||||
or None if include_event_overlay=False,
|
||||
"notes": str | None,
|
||||
# — gap-only fields —
|
||||
"context": "between assignments" | None,
|
||||
# — state_change-only fields —
|
||||
"change_type": str,
|
||||
"field_name": str | None,
|
||||
"old_value": str | None,
|
||||
"new_value": str | None,
|
||||
"source": str,
|
||||
"history_notes": str | None,
|
||||
},
|
||||
... # newest first
|
||||
],
|
||||
}
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit:
|
||||
return {"unit_id": unit_id, "device_type": None, "entries": []}
|
||||
|
||||
# 1. Load assignments + their location/project lookups in bulk.
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.unit_id == unit_id)
|
||||
.order_by(UnitAssignment.assigned_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
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 {}
|
||||
|
||||
# 2. Load relevant unit_history rows. We surface state changes that
|
||||
# operators care about on a deployment timeline: calibration status,
|
||||
# retirement, deployed flag, allocation, calibration date, and the
|
||||
# assignment_* events we just added (those are redundant with the
|
||||
# assignment rows themselves, so we skip them to avoid double-rendering).
|
||||
interesting_change_types = (
|
||||
"calibration_status_change",
|
||||
"retired_change",
|
||||
"deployed_change",
|
||||
"allocation_change",
|
||||
"last_calibrated_change",
|
||||
"next_calibration_due_change",
|
||||
)
|
||||
history = (
|
||||
db.query(UnitHistory)
|
||||
.filter(UnitHistory.unit_id == unit_id)
|
||||
.filter(UnitHistory.change_type.in_(interesting_change_types))
|
||||
.order_by(UnitHistory.changed_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 3. Optionally fetch SFM event overlay for each assignment window.
|
||||
# Concurrent fan-out via httpx + asyncio.gather.
|
||||
overlays: dict[str, dict] = {}
|
||||
if include_event_overlay and assignments and unit.device_type == "seismograph":
|
||||
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT) as client:
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
_fetch_events_for_serial(
|
||||
client,
|
||||
serial=unit_id,
|
||||
from_dt=a.assigned_at,
|
||||
to_dt=a.assigned_until or now,
|
||||
false_trigger=None,
|
||||
limit=_SFM_FETCH_CEILING,
|
||||
)
|
||||
for a in assignments
|
||||
),
|
||||
return_exceptions=False,
|
||||
)
|
||||
for a, events in zip(assignments, results):
|
||||
peak = None
|
||||
peak_at = None
|
||||
last_ev = None
|
||||
for ev in events:
|
||||
pvs = ev.get("peak_vector_sum")
|
||||
if pvs is not None and (peak is None or pvs > peak):
|
||||
peak = pvs
|
||||
peak_at = ev.get("timestamp")
|
||||
ts = ev.get("timestamp")
|
||||
if ts and (last_ev is None or ts > last_ev):
|
||||
last_ev = ts
|
||||
overlays[a.id] = {
|
||||
"event_count": len(events),
|
||||
"peak_pvs": peak,
|
||||
"peak_pvs_at": peak_at,
|
||||
"last_event": last_ev,
|
||||
}
|
||||
|
||||
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||
# consecutive assignments, then add state-change rows from unit_history.
|
||||
entries: list[dict] = []
|
||||
|
||||
for idx, a in enumerate(assignments):
|
||||
loc = loc_map.get(a.location_id)
|
||||
proj = proj_map.get(a.project_id)
|
||||
is_active = a.assigned_until is None
|
||||
ends_at = a.assigned_until or now
|
||||
duration_days = (ends_at - a.assigned_at).total_seconds() / 86400 if a.assigned_at else None
|
||||
|
||||
entry = {
|
||||
"kind": "assignment",
|
||||
"starts_at": _iso_utc(a.assigned_at),
|
||||
"ends_at": _iso_utc(a.assigned_until),
|
||||
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
||||
"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,
|
||||
"is_active": is_active,
|
||||
"notes": a.notes,
|
||||
"event_overlay": overlays.get(a.id),
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
# Gap detection: from the end of this assignment to the start of the
|
||||
# next one. Only emit gaps that are at least _MIN_GAP_SECONDS long
|
||||
# so trivial sub-second handoffs during swaps don't clutter the view.
|
||||
if idx + 1 < len(assignments):
|
||||
next_a = assignments[idx + 1]
|
||||
gap_start = a.assigned_until or now
|
||||
gap_end = next_a.assigned_at
|
||||
gap_seconds = (gap_end - gap_start).total_seconds() if gap_end and gap_start else 0
|
||||
if gap_seconds >= _MIN_GAP_SECONDS:
|
||||
entries.append({
|
||||
"kind": "gap",
|
||||
"starts_at": _iso_utc(gap_start),
|
||||
"ends_at": _iso_utc(gap_end),
|
||||
"duration_days": round(gap_seconds / 86400, 1),
|
||||
"context": "between assignments",
|
||||
})
|
||||
|
||||
# 5. State changes — interleaved by timestamp. Skip no-op rows where
|
||||
# old_value == new_value (an artifact of the legacy record_history()
|
||||
# being called on every save regardless of whether the field changed).
|
||||
for h in history:
|
||||
if h.old_value == h.new_value:
|
||||
continue
|
||||
entries.append({
|
||||
"kind": "state_change",
|
||||
"starts_at": _iso_utc(h.changed_at),
|
||||
"ends_at": None,
|
||||
"duration_days": None,
|
||||
"change_type": h.change_type,
|
||||
"field_name": h.field_name,
|
||||
"old_value": h.old_value,
|
||||
"new_value": h.new_value,
|
||||
"source": h.source,
|
||||
"history_notes": h.notes,
|
||||
})
|
||||
|
||||
# 6. Sort newest first. Active assignments (no end) sort by start time,
|
||||
# same as everything else.
|
||||
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
||||
|
||||
return {
|
||||
"unit_id": unit.id,
|
||||
"device_type": unit.device_type,
|
||||
"entries": entries,
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
"""
|
||||
SFM events service — bridge between terra-view's UnitAssignment time-windows
|
||||
and the SFM (seismo-relay) events store.
|
||||
|
||||
Architecture:
|
||||
1. Terra-view owns the *assignment graph*: which seismograph was at which
|
||||
monitoring location during which time window (UnitAssignment rows).
|
||||
2. SFM owns the *events store*: triggered waveform events keyed by
|
||||
(serial, timestamp), forwarded from Blastware ACH by series3-watcher.
|
||||
3. This module fans out the assignments for a given location, queries SFM
|
||||
for the events emitted by each (serial, window) pair concurrently, and
|
||||
unions/sorts/paginates the results.
|
||||
|
||||
SFM remains the single source of truth for events. Terra-view does not
|
||||
copy events into its own DB; every query hits SFM live.
|
||||
|
||||
The events_for_location helper is also reused by Phase 3 (project-level
|
||||
roll-up) to aggregate across every location in a project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import UnitAssignment, RosterUnit, MonitoringLocation, Project
|
||||
|
||||
log = logging.getLogger("backend.services.sfm_events")
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
# Per-request timeout when calling SFM /db/events. SFM is local on the
|
||||
# docker network so this should be fast; bump if you start seeing timeouts.
|
||||
_SFM_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Max events we ever fetch per (serial, window) call to SFM. Must match
|
||||
# SFM's own /db/events max limit (currently 5000). The user-facing display
|
||||
# limit is independent — we over-fetch up to this cap so summary stats are
|
||||
# accurate, then trim the displayed list to the requested limit.
|
||||
_SFM_FETCH_CEILING = 5000
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _iso_utc(dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Render a datetime in the ISO format SFM /db/events expects."""
|
||||
if dt is None:
|
||||
return None
|
||||
# SFM parses naive ISO strings as UTC; strip tzinfo for consistency.
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt.isoformat(sep=" ", timespec="seconds")
|
||||
|
||||
|
||||
def _intersect_window(
|
||||
assignment_start: datetime,
|
||||
assignment_end: Optional[datetime],
|
||||
filter_from: Optional[datetime],
|
||||
filter_to: Optional[datetime],
|
||||
now: datetime,
|
||||
) -> Optional[tuple[datetime, datetime]]:
|
||||
"""Intersect an assignment window with the requested filter window.
|
||||
|
||||
Returns (effective_start, effective_end) or None if there's no overlap.
|
||||
Open-ended assignments (assigned_until=NULL) are bounded by `now`.
|
||||
"""
|
||||
a_end = assignment_end or now
|
||||
if filter_from and a_end <= filter_from:
|
||||
return None
|
||||
if filter_to and assignment_start >= filter_to:
|
||||
return None
|
||||
start = max(assignment_start, filter_from) if filter_from else assignment_start
|
||||
end = min(a_end, filter_to) if filter_to else a_end
|
||||
if end <= start:
|
||||
return None
|
||||
return (start, end)
|
||||
|
||||
|
||||
async def _fetch_events_for_serial(
|
||||
client: httpx.AsyncClient,
|
||||
serial: str,
|
||||
*,
|
||||
from_dt: datetime,
|
||||
to_dt: datetime,
|
||||
false_trigger: Optional[bool],
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Issue one /db/events call to SFM for one (serial, window) pair."""
|
||||
params: dict[str, str] = {
|
||||
"serial": serial,
|
||||
"from_dt": _iso_utc(from_dt) or "",
|
||||
"to_dt": _iso_utc(to_dt) or "",
|
||||
"limit": str(limit),
|
||||
}
|
||||
if false_trigger is not None:
|
||||
params["false_trigger"] = "true" if false_trigger else "false"
|
||||
|
||||
try:
|
||||
resp = await client.get(f"{SFM_BASE_URL}/db/events", params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/events failed for serial=%s: %s", serial, e)
|
||||
return []
|
||||
|
||||
payload = resp.json()
|
||||
events = payload.get("events", []) or []
|
||||
# Strip waveform_blob if present — it's the big per-event binary and we
|
||||
# don't render it in the list view. SFM returns it by default.
|
||||
for ev in events:
|
||||
ev.pop("waveform_blob", None)
|
||||
ev.pop("a5_pickle_filename", None)
|
||||
return events
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def events_for_location(
|
||||
db: Session,
|
||||
location_id: str,
|
||||
*,
|
||||
from_dt: Optional[datetime] = None,
|
||||
to_dt: Optional[datetime] = None,
|
||||
false_trigger: Optional[bool] = None,
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""Fan out UnitAssignment rows for `location_id` and union SFM events.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"events": [merged event dicts, newest first, capped at limit],
|
||||
"count": total events found across all windows (pre-cap),
|
||||
"stats": {event_count, peak_pvs, peak_pvs_at,
|
||||
last_event, false_trigger_count},
|
||||
"assignments_used": [{unit_id, assigned_at, assigned_until,
|
||||
events_in_window}, ...],
|
||||
}
|
||||
|
||||
The "events outside any assignment window" rule (Phase 1 design decision):
|
||||
events whose timestamp falls outside every assignment window are simply
|
||||
not fetched — we only ask SFM for events inside the intersected windows.
|
||||
Those orphan events surface under the per-unit detail page in Phase 2.
|
||||
"""
|
||||
# 1. Fetch all assignments (active + closed) for the location.
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == location_id)
|
||||
.filter(UnitAssignment.device_type == "seismograph")
|
||||
.order_by(UnitAssignment.assigned_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not assignments:
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": _empty_stats(),
|
||||
"assignments_used": [],
|
||||
}
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 2. For each assignment, compute the effective (start, end) window after
|
||||
# intersecting with the requested filter range. Drop assignments that
|
||||
# don't overlap the filter window.
|
||||
fetch_specs: list[tuple[UnitAssignment, datetime, datetime]] = []
|
||||
for a in assignments:
|
||||
window = _intersect_window(a.assigned_at, a.assigned_until, from_dt, to_dt, now)
|
||||
if window is not None:
|
||||
fetch_specs.append((a, window[0], window[1]))
|
||||
|
||||
if not fetch_specs:
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": _empty_stats(),
|
||||
"assignments_used": [
|
||||
{
|
||||
"unit_id": a.unit_id,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
"events_in_window": 0,
|
||||
}
|
||||
for a in assignments
|
||||
],
|
||||
}
|
||||
|
||||
# 3. Concurrent SFM fetches. We over-fetch (up to _SFM_FETCH_CEILING per
|
||||
# window) so summary stats reflect the true peak/last/count across the
|
||||
# full filter window, not just what fits in the user's display limit.
|
||||
# The displayed event list is trimmed to `limit` after merge.
|
||||
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
|
||||
per_window_lists = await asyncio.gather(
|
||||
*(
|
||||
_fetch_events_for_serial(
|
||||
client,
|
||||
serial=a.unit_id,
|
||||
from_dt=start,
|
||||
to_dt=end,
|
||||
false_trigger=false_trigger,
|
||||
limit=_SFM_FETCH_CEILING,
|
||||
)
|
||||
for a, start, end in fetch_specs
|
||||
),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
# 4. Build the per-assignment event counts (transparency for the operator).
|
||||
spec_event_counts: dict[str, int] = {}
|
||||
for (a, _start, _end), evs in zip(fetch_specs, per_window_lists):
|
||||
spec_event_counts[a.id] = len(evs)
|
||||
|
||||
# 5. Union, sort newest-first, cap.
|
||||
merged: list[dict] = []
|
||||
for evs in per_window_lists:
|
||||
merged.extend(evs)
|
||||
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||
total_count = len(merged)
|
||||
capped = merged[:limit]
|
||||
|
||||
# 6. Compute summary stats over the full merged set (not the capped one).
|
||||
stats = _compute_stats(merged)
|
||||
|
||||
# 7. Build the assignments_used report (every assignment, in chronological
|
||||
# order, with its event count — even ones that fell outside the filter
|
||||
# window so the operator sees them but with count=0).
|
||||
assignments_used = []
|
||||
for a in assignments:
|
||||
assignments_used.append(
|
||||
{
|
||||
"unit_id": a.unit_id,
|
||||
"assignment_id": a.id,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
"events_in_window": spec_event_counts.get(a.id, 0),
|
||||
"status": a.status,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"events": capped,
|
||||
"count": total_count,
|
||||
"stats": stats,
|
||||
"assignments_used": assignments_used,
|
||||
}
|
||||
|
||||
|
||||
# ── 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),
|
||||
}
|
||||
|
||||
|
||||
# ── Project-level roll-up (aggregates across all vibration locations) ─────────
|
||||
|
||||
|
||||
async def vibration_summary_for_project(
|
||||
db: Session,
|
||||
project_id: str,
|
||||
*,
|
||||
from_dt: Optional[datetime] = None,
|
||||
to_dt: Optional[datetime] = None,
|
||||
) -> dict:
|
||||
"""Aggregate SFM events across every vibration location in a project.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"project_id": str,
|
||||
"total_events": int,
|
||||
"peak_pvs": float | None,
|
||||
"peak_pvs_at": ISO timestamp | None,
|
||||
"peak_pvs_location_id": str | None,
|
||||
"peak_pvs_location_name": str | None,
|
||||
"last_event": ISO timestamp | None,
|
||||
"false_trigger_count": int,
|
||||
"per_location": [
|
||||
{"location_id", "location_name", "event_count",
|
||||
"peak_pvs", "last_event"},
|
||||
... # sorted by event_count DESC
|
||||
],
|
||||
"vibration_location_count": int,
|
||||
}
|
||||
"""
|
||||
locations = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == project_id)
|
||||
.filter(MonitoringLocation.location_type == "vibration")
|
||||
.all()
|
||||
)
|
||||
|
||||
if not locations:
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"total_events": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_location_id": None,
|
||||
"peak_pvs_location_name": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
"per_location": [],
|
||||
"vibration_location_count": 0,
|
||||
}
|
||||
|
||||
# Fan out across locations. Each call internally fans out across that
|
||||
# location's UnitAssignment rows, so this is a nested fan-out. Both
|
||||
# tiers happen concurrently because asyncio.gather + httpx pool.
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
events_for_location(
|
||||
db,
|
||||
loc.id,
|
||||
from_dt=from_dt,
|
||||
to_dt=to_dt,
|
||||
false_trigger=None,
|
||||
limit=1, # We only need stats; events list itself is ignored.
|
||||
)
|
||||
for loc in locations
|
||||
),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
per_location: list[dict] = []
|
||||
total_events = 0
|
||||
peak_pvs = None
|
||||
peak_pvs_at = None
|
||||
peak_pvs_location_id = None
|
||||
peak_pvs_location_name = None
|
||||
last_event = None
|
||||
false_trigger_count = 0
|
||||
|
||||
for loc, res in zip(locations, results):
|
||||
st = res.get("stats", {}) or {}
|
||||
ec = st.get("event_count", 0) or 0
|
||||
total_events += ec
|
||||
false_trigger_count += st.get("false_trigger_count", 0) or 0
|
||||
|
||||
ev_last = st.get("last_event")
|
||||
if ev_last and (last_event is None or ev_last > last_event):
|
||||
last_event = ev_last
|
||||
|
||||
ev_peak = st.get("peak_pvs")
|
||||
if ev_peak is not None and (peak_pvs is None or ev_peak > peak_pvs):
|
||||
peak_pvs = ev_peak
|
||||
peak_pvs_at = st.get("peak_pvs_at")
|
||||
peak_pvs_location_id = loc.id
|
||||
peak_pvs_location_name = loc.name
|
||||
|
||||
per_location.append({
|
||||
"location_id": loc.id,
|
||||
"location_name": loc.name,
|
||||
"event_count": ec,
|
||||
"peak_pvs": ev_peak,
|
||||
"last_event": ev_last,
|
||||
})
|
||||
|
||||
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"total_events": total_events,
|
||||
"peak_pvs": peak_pvs,
|
||||
"peak_pvs_at": peak_pvs_at,
|
||||
"peak_pvs_location_id": peak_pvs_location_id,
|
||||
"peak_pvs_location_name": peak_pvs_location_name,
|
||||
"last_event": last_event,
|
||||
"false_trigger_count": false_trigger_count,
|
||||
"per_location": per_location,
|
||||
"vibration_location_count": len(locations),
|
||||
}
|
||||
|
||||
|
||||
# ── Stats helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _empty_stats() -> dict:
|
||||
return {
|
||||
"event_count": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_serial": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _compute_stats(events: list[dict]) -> dict:
|
||||
"""Roll up summary stats from a merged event list. Cheap O(N) pass."""
|
||||
if not events:
|
||||
return _empty_stats()
|
||||
|
||||
peak_pvs = None
|
||||
peak_pvs_at = None
|
||||
peak_pvs_serial = None
|
||||
last_event = None
|
||||
false_trigger_count = 0
|
||||
|
||||
for ev in events:
|
||||
pvs = ev.get("peak_vector_sum")
|
||||
if pvs is not None and (peak_pvs is None or pvs > peak_pvs):
|
||||
peak_pvs = pvs
|
||||
peak_pvs_at = ev.get("timestamp")
|
||||
peak_pvs_serial = ev.get("serial")
|
||||
|
||||
ts = ev.get("timestamp")
|
||||
if ts and (last_event is None or ts > last_event):
|
||||
last_event = ts
|
||||
|
||||
if ev.get("false_trigger"):
|
||||
false_trigger_count += 1
|
||||
|
||||
return {
|
||||
"event_count": len(events),
|
||||
"peak_pvs": peak_pvs,
|
||||
"peak_pvs_at": peak_pvs_at,
|
||||
"peak_pvs_serial": peak_pvs_serial,
|
||||
"last_event": last_event,
|
||||
"false_trigger_count": false_trigger_count,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{# Project-wide vibration events roll-up. Loaded via HTMX. #}
|
||||
{% if summary.vibration_location_count == 0 %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No vibration monitoring locations yet. Add one to start collecting events.
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Project-wide vibration events
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
across {{ summary.vibration_location_count }} location{{ '' if summary.vibration_location_count == 1 else 's' }}
|
||||
</span>
|
||||
</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 class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "{:,}".format(summary.total_events) }}</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>
|
||||
{% if summary.peak_pvs is not none %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.4f"|format(summary.peak_pvs) }} <span class="text-sm font-normal">in/s</span></span>
|
||||
<a href="/projects/{{ summary.project_id }}/nrl/{{ summary.peak_pvs_location_id }}"
|
||||
class="text-xs text-seismo-orange hover:text-seismo-navy truncate mt-1"
|
||||
title="{{ summary.peak_pvs_location_name }}">
|
||||
{{ summary.peak_pvs_location_name }}
|
||||
{% if summary.peak_pvs_at %} · {{ summary.peak_pvs_at[:10] }}{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% if summary.last_event %}
|
||||
<span class="text-base font-bold text-gray-900 dark:text-white mt-1">{{ summary.last_event[:19].replace('T', ' ') }}</span>
|
||||
{% else %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
{% endif %}
|
||||
</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">False Triggers</span>
|
||||
<span class="text-2xl font-bold {% if summary.false_trigger_count > 0 %}text-amber-600 dark:text-amber-400{% else %}text-gray-900 dark:text-white{% endif %} mt-1">{{ "{:,}".format(summary.false_trigger_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if summary.per_location and summary.total_events > 0 %}
|
||||
<!-- Top locations by activity -->
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Top locations by activity</h4>
|
||||
<div class="space-y-1.5">
|
||||
{% for loc in summary.per_location[:5] %}
|
||||
<a href="/projects/{{ summary.project_id }}/nrl/{{ loc.location_id }}"
|
||||
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
📍 {{ loc.location_name }}
|
||||
</span>
|
||||
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
||||
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
||||
{% if loc.peak_pvs is not none %}
|
||||
<span class="text-xs text-gray-500">peak {{ "%.4f"|format(loc.peak_pvs) }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -90,6 +90,17 @@
|
||||
|
||||
<!-- Vibration Locations sub-panel -->
|
||||
<div id="vib-sub-locations" class="vib-sub-panel">
|
||||
<!-- Project-wide vibration events roll-up -->
|
||||
<div id="vibration-summary"
|
||||
hx-get="/api/projects/{{ project_id }}/vibration_summary"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-5">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Loading project summary…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
||||
|
||||
+436
-18
@@ -278,30 +278,106 @@
|
||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||
</div>
|
||||
|
||||
<!-- Deployment History -->
|
||||
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
||||
unit_assignments + unit_history + SFM event overlay) -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment History</h3>
|
||||
<button onclick="openNewDeploymentModal()" class="px-3 py-1.5 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm transition-colors flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Log Deployment
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
||||
<button onclick="loadDeploymentTimeline()" 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>
|
||||
<div id="deploymentHistory" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
<div id="deploymentTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit History Timeline -->
|
||||
<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>
|
||||
<div id="historyTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
|
||||
<!-- 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>
|
||||
|
||||
|
||||
<!-- Photos -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
@@ -1547,8 +1623,15 @@ async function uploadPhoto(file) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load and display unit history timeline
|
||||
// Legacy timeline loader — Phase 4 unified the timeline view. Now a shim
|
||||
// that delegates to loadDeploymentTimeline() so existing callers from modal
|
||||
// save handlers still trigger a refresh of the visible section.
|
||||
async function loadUnitHistory() {
|
||||
if (typeof loadDeploymentTimeline === 'function') {
|
||||
return loadDeploymentTimeline();
|
||||
}
|
||||
}
|
||||
async function _legacy_loadUnitHistory_unused() {
|
||||
try {
|
||||
const response = await fetch(`/api/roster/history/${unitId}`);
|
||||
if (!response.ok) {
|
||||
@@ -1720,10 +1803,18 @@ async function pingModem() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Deployment History
|
||||
// Deployment History (legacy — Phase 4 superseded by deployment_timeline)
|
||||
// ============================================================
|
||||
|
||||
// Phase 4 shim: delegate to the unified timeline loader so existing modal
|
||||
// save handlers (legacy "Log Deployment" form, edit-save callbacks) still
|
||||
// trigger a refresh of the visible Deployment Timeline section.
|
||||
async function loadDeploymentHistory() {
|
||||
if (typeof loadDeploymentTimeline === 'function') {
|
||||
return loadDeploymentTimeline();
|
||||
}
|
||||
}
|
||||
async function _legacy_loadDeploymentHistory_unused() {
|
||||
try {
|
||||
const res = await fetch(`/api/deployments/${unitId}`);
|
||||
const data = await res.json();
|
||||
@@ -1884,10 +1975,337 @@ loadCalibrationInterval();
|
||||
setupCalibrationAutoCalc();
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
loadDeploymentHistory();
|
||||
loadDeploymentTimeline();
|
||||
if (currentUnit && currentUnit.device_type === 'seismograph') {
|
||||
document.getElementById('sfmEventsSection').classList.remove('hidden');
|
||||
loadUnitEvents();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Unified Deployment Timeline (Phase 4) ────────────────────────────────────
|
||||
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
||||
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
||||
|
||||
async function loadDeploymentTimeline() {
|
||||
const container = document.getElementById('deploymentTimeline');
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
renderDeploymentTimeline(d.entries || [], container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _dtFmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
function _dtFmtDateTime(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function _dtEsc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _dtPpvClass(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 _dtRenderAssignment(e) {
|
||||
const start = _dtFmtDate(e.starts_at);
|
||||
const end = e.is_active ? 'present' : _dtFmtDate(e.ends_at);
|
||||
const dur = (e.duration_days != null)
|
||||
? `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'})</span>`
|
||||
: '';
|
||||
const ov = e.event_overlay || {};
|
||||
const evCount = ov.event_count ?? 0;
|
||||
const peak = ov.peak_pvs;
|
||||
|
||||
const locLink = e.location_id
|
||||
? `<a href="/projects/${_dtEsc(e.project_id)}/nrl/${_dtEsc(e.location_id)}" class="text-seismo-orange hover:text-seismo-navy font-medium">📍 ${_dtEsc(e.location_name || 'unnamed location')}</a>`
|
||||
: `<span class="text-gray-500 dark:text-gray-400 italic">📍 (no location FK — synthesized from legacy deployment_records)</span>`;
|
||||
|
||||
const projLine = e.project_name
|
||||
? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${_dtEsc(e.project_name)}</div>`
|
||||
: '';
|
||||
|
||||
const activeBadge = e.is_active
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
|
||||
const overlay = evCount > 0
|
||||
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
||||
${peak != null ? `<span>peak <strong class="${_dtPpvClass(peak)}">${peak.toFixed(4)} in/s</strong></span>` : ''}
|
||||
${ov.last_event ? `<span>last ${_dtFmtDateTime(ov.last_event)}</span>` : ''}
|
||||
</div>`
|
||||
: `<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">No events recorded during this window.</div>`;
|
||||
|
||||
const notes = e.notes
|
||||
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
||||
</div>
|
||||
${activeBadge}
|
||||
</div>
|
||||
<div class="mt-1">${locLink}</div>
|
||||
${projLine}
|
||||
${overlay}
|
||||
${notes}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _dtRenderGap(e) {
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full border-2 border-gray-400 dark:border-gray-500"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50/40 dark:bg-slate-900/20 rounded-lg p-3 border border-dashed border-gray-300 dark:border-gray-700">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>${_dtFmtDate(e.starts_at)}</strong> → <strong>${_dtFmtDate(e.ends_at)}</strong>
|
||||
<span class="text-xs ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'} idle)</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No active assignment</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _dtRenderStateChange(e) {
|
||||
// Friendly labels for known change_types.
|
||||
const labels = {
|
||||
deployed_change: 'Deployed status changed',
|
||||
retired_change: 'Retired status changed',
|
||||
calibration_status_change: 'Calibration status changed',
|
||||
last_calibrated_change: 'Last calibrated updated',
|
||||
next_calibration_due_change: 'Next calibration due updated',
|
||||
allocation_change: 'Allocation changed',
|
||||
};
|
||||
const label = labels[e.change_type] || e.change_type;
|
||||
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full bg-seismo-navy"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50 dark:bg-slate-900/30 rounded-lg p-3">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
📅 <strong>${_dtFmtDateTime(e.starts_at)}</strong> — ${_dtEsc(label)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
${_dtEsc(e.old_value || '—')} → <strong>${_dtEsc(e.new_value || '—')}</strong>
|
||||
</div>
|
||||
${e.history_notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">${_dtEsc(e.history_notes)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDeploymentTimeline(entries, container) {
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
||||
return;
|
||||
}
|
||||
const html = entries.map(e => {
|
||||
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
||||
if (e.kind === 'gap') return _dtRenderGap(e);
|
||||
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
||||
return '';
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── 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 =====
|
||||
let pairModalModems = []; // Cache loaded modems
|
||||
let pairModalDeviceType = ''; // Current device type
|
||||
|
||||
@@ -65,6 +65,11 @@
|
||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||
Overview
|
||||
</button>
|
||||
<button onclick="switchTab('events')"
|
||||
data-tab="events"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Events
|
||||
</button>
|
||||
<button onclick="switchTab('settings')"
|
||||
data-tab="settings"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
@@ -185,6 +190,152 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Tab -->
|
||||
<div id="events-tab" class="tab-panel hidden">
|
||||
<!-- Summary stats -->
|
||||
<div id="events-stats" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||
<span id="ev-stat-count" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
|
||||
<span id="ev-stat-peak" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span id="ev-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||
<span id="ev-stat-last" class="text-lg font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
|
||||
<span id="ev-stat-ft" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignments used (transparency: which seismographs contributed events) -->
|
||||
<div id="events-assignments-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Seismographs deployed at this location</h3>
|
||||
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<span class="inline-block w-4 text-center">✎</span>
|
||||
Click the pencil to backdate a deployment so historical events get attributed to this location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit-assignment modal -->
|
||||
<div id="assignment-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Edit Deployment Window</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span id="ae-unit-label" class="font-mono text-seismo-orange">—</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="closeAssignmentEditModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="assignment-edit-form" class="p-6 space-y-4">
|
||||
<input type="hidden" id="ae-assignment-id">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned From</label>
|
||||
<input type="datetime-local" id="ae-assigned-at" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Assigned Until
|
||||
<span class="text-xs text-gray-500 ml-1">(leave blank if still active)</span>
|
||||
</label>
|
||||
<input type="datetime-local" id="ae-assigned-until"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="ae-notes" rows="2" placeholder="Optional — e.g. 'backdated to reflect physical install date'"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="ae-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="closeAssignmentEditModal()"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="ae-submit-btn"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<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="ev-filter-from" onchange="loadLocationEvents()"
|
||||
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="ev-filter-to" onchange="loadLocationEvents()"
|
||||
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="ev-filter-ft" onchange="loadLocationEvents()"
|
||||
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 Events</option>
|
||||
<option value="false">Real Events Only</option>
|
||||
<option value="true">False Triggers 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="ev-filter-limit" onchange="loadLocationEvents()"
|
||||
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="clearEventFilters()"
|
||||
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>
|
||||
<button onclick="loadLocationEvents()"
|
||||
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event table -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||
<div id="events-container" class="overflow-x-auto">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
|
||||
Loading events…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="settings-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
@@ -324,6 +475,258 @@ function switchTab(tabName) {
|
||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
// Lazy-load Events tab on first visit (or whenever it's reopened).
|
||||
if (tabName === 'events' && !_eventsLoaded) {
|
||||
loadLocationEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events tab ───────────────────────────────────────────────────────────────
|
||||
let _eventsLoaded = false;
|
||||
|
||||
function clearEventFilters() {
|
||||
document.getElementById('ev-filter-from').value = '';
|
||||
document.getElementById('ev-filter-to').value = '';
|
||||
document.getElementById('ev-filter-ft').value = '';
|
||||
document.getElementById('ev-filter-limit').value = '500';
|
||||
loadLocationEvents();
|
||||
}
|
||||
|
||||
async function loadLocationEvents() {
|
||||
const container = document.getElementById('events-container');
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const from = document.getElementById('ev-filter-from').value;
|
||||
const to = document.getElementById('ev-filter-to').value;
|
||||
const ft = document.getElementById('ev-filter-ft').value;
|
||||
const limit = document.getElementById('ev-filter-limit').value;
|
||||
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/projects/${projectId}/locations/${locationId}/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();
|
||||
_eventsLoaded = true;
|
||||
renderEventStats(d.stats);
|
||||
renderAssignmentsUsed(d.assignments_used);
|
||||
renderEventTable(d.events, d.count, container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEventStats(stats) {
|
||||
const s = stats || {};
|
||||
document.getElementById('ev-stat-count').textContent = (s.event_count ?? 0).toLocaleString();
|
||||
document.getElementById('ev-stat-ft').textContent = (s.false_trigger_count ?? 0).toLocaleString();
|
||||
|
||||
if (s.peak_pvs != null) {
|
||||
document.getElementById('ev-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
|
||||
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
|
||||
const who = s.peak_pvs_serial || '';
|
||||
document.getElementById('ev-stat-peak-when').textContent = [when, who].filter(Boolean).join(' · ') || '—';
|
||||
} else {
|
||||
document.getElementById('ev-stat-peak').textContent = '—';
|
||||
document.getElementById('ev-stat-peak-when').textContent = '—';
|
||||
}
|
||||
|
||||
if (s.last_event) {
|
||||
const dt = s.last_event.slice(0, 19).replace('T', ' ');
|
||||
document.getElementById('ev-stat-last').textContent = dt;
|
||||
} else {
|
||||
document.getElementById('ev-stat-last').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignmentsUsed(assignments) {
|
||||
const card = document.getElementById('events-assignments-card');
|
||||
const listEl = document.getElementById('ev-assignments-list');
|
||||
const countEl = document.getElementById('ev-assignments-count');
|
||||
|
||||
if (!assignments || assignments.length === 0) {
|
||||
card.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
card.classList.remove('hidden');
|
||||
countEl.textContent = `${assignments.length} assignment${assignments.length === 1 ? '' : 's'}`;
|
||||
|
||||
listEl.innerHTML = assignments.map(a => {
|
||||
const start = a.assigned_at ? a.assigned_at.slice(0, 10) : '?';
|
||||
const end = a.assigned_until ? a.assigned_until.slice(0, 10) : 'present';
|
||||
const isActive = !a.assigned_until;
|
||||
const badge = isActive
|
||||
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
const editAttr = encodeURIComponent(JSON.stringify({
|
||||
id: a.assignment_id,
|
||||
unit_id: a.unit_id,
|
||||
assigned_at: a.assigned_at,
|
||||
assigned_until: a.assigned_until,
|
||||
}));
|
||||
return `<div class="py-2 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
|
||||
${badge}
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${start} → ${end}</span>
|
||||
<button type="button"
|
||||
onclick="openAssignmentEditModal('${editAttr}')"
|
||||
title="Edit deployment dates"
|
||||
class="text-gray-400 hover:text-seismo-orange transition-colors p-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Assignment-edit modal ───────────────────────────────────────────────────
|
||||
function _isoToInputValue(iso) {
|
||||
// Convert "2026-04-14T02:19:27" (or "2026-04-14 02:19:27") to "2026-04-14T02:19" for datetime-local input.
|
||||
if (!iso) return '';
|
||||
const cleaned = iso.replace(' ', 'T');
|
||||
return cleaned.slice(0, 16);
|
||||
}
|
||||
|
||||
function openAssignmentEditModal(encodedJson) {
|
||||
const data = JSON.parse(decodeURIComponent(encodedJson));
|
||||
document.getElementById('ae-assignment-id').value = data.id;
|
||||
document.getElementById('ae-unit-label').textContent = data.unit_id;
|
||||
document.getElementById('ae-assigned-at').value = _isoToInputValue(data.assigned_at);
|
||||
document.getElementById('ae-assigned-until').value = _isoToInputValue(data.assigned_until);
|
||||
document.getElementById('ae-notes').value = '';
|
||||
document.getElementById('ae-error').classList.add('hidden');
|
||||
document.getElementById('assignment-edit-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAssignmentEditModal() {
|
||||
document.getElementById('assignment-edit-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('assignment-edit-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('ae-error');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const assignmentId = document.getElementById('ae-assignment-id').value;
|
||||
const assignedAt = document.getElementById('ae-assigned-at').value;
|
||||
const assignedUntil = document.getElementById('ae-assigned-until').value;
|
||||
const notes = document.getElementById('ae-notes').value.trim();
|
||||
|
||||
if (!assignedAt) {
|
||||
errEl.textContent = 'Assigned From is required.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { assigned_at: assignedAt };
|
||||
payload.assigned_until = assignedUntil || null;
|
||||
if (notes) payload.notes = notes;
|
||||
|
||||
const btn = document.getElementById('ae-submit-btn');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
closeAssignmentEditModal();
|
||||
await loadLocationEvents(); // Refresh stats + table with new window.
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || 'Failed to update assignment.';
|
||||
errEl.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Save';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('assignment-edit-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeAssignmentEditModal();
|
||||
});
|
||||
|
||||
function renderEventTable(events, total, container) {
|
||||
if (!events || events.length === 0) {
|
||||
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');
|
||||
const msg = haveAssignments
|
||||
? 'No events recorded for the assignments above within the current filter.'
|
||||
: 'No seismographs have been assigned to this location yet. Assign one to start collecting events.';
|
||||
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 = fmtPPV(ev.tran_ppv);
|
||||
const vert = fmtPPV(ev.vert_ppv);
|
||||
const lng = fmtPPV(ev.long_ppv);
|
||||
const pvs = fmtPPV(ev.peak_vector_sum);
|
||||
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
|
||||
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">
|
||||
<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 font-medium text-seismo-orange">
|
||||
<a href="/unit/${esc(ev.serial)}" class="hover:text-seismo-navy">${esc(ev.serial)}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.tran_ppv)}">${tran}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.vert_ppv)}">${vert}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
|
||||
<td class="px-4 py-2.5 text-sm">${ft}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3">Showing ${events.length} of ${total.toLocaleString()} events</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">Serial</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">Mic</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function fmtPPV(v) {
|
||||
if (v == null) return '—';
|
||||
return v.toFixed(4);
|
||||
}
|
||||
|
||||
function ppvClass(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 esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Location settings form submission
|
||||
|
||||
Reference in New Issue
Block a user