5 Commits

Author SHA1 Message Date
serversdown f1f3da8e61 feat(sfm): unified deployment timeline (deprecate deployment_records)
Phase 4.  Rebuilds the seismograph "Deployment History" + "Timeline"
sections on the unit detail page as a single derived view computed from
three sources: unit_assignments (authoritative project/location windows),
unit_history (calibration/retirement/deployed state changes), and SFM
events overlaid per assignment window (count + peak PVS + last event).

Fixes the wonky-timeline symptoms: missing entries, duplicate/contradictory
rows, and no visibility into what the unit was actually doing during each
deployment window.

Backend:
- backend/services/deployment_timeline.py: new deployment_timeline_for_unit()
  helper.  Merges UnitAssignment rows (with SFM event overlay fetched
  concurrently via httpx), UnitHistory state-change rows (filtered to
  meaningful change_types and de-noised by dropping rows where
  old_value == new_value — there's noise in legacy audit log from
  record_history() being called on every save), and synthetic "gap"
  entries between assignments >= 1 day apart.  Sorts newest first.

- backend/routers/units.py: new GET /api/units/{unit_id}/deployment_timeline
  endpoint with optional include_events=false flag.

- backend/routers/project_locations.py: assign / unassign / swap /
  update endpoints now write UnitHistory rows on every assignment
  lifecycle event.  New change_types: assignment_created,
  assignment_ended, assignment_swapped, assignment_updated.  These
  surface in the unified timeline (where the assignment row itself
  shows the structural data; the audit row is filtered out to avoid
  double-rendering).  Closes a real gap — assignment changes were
  previously invisible to any audit consumer.

- backend/migrate_deprecate_deployment_records.py: non-destructive
  migration.  Adds deployment_records.deprecated_at column.  For each
  legacy row without a matching UnitAssignment, best-effort
  synthesizes one (with the free-text location_name preserved in
  notes).  Marks every processed row.  Idempotent.  DROP TABLE
  deferred to a follow-up release.

Frontend (templates/unit_detail.html):
- Removed legacy "Deployment History" card (with Log Deployment button)
  and the separate "Timeline" card.  Replaced with a single
  "Deployment Timeline" section.
- Three entry visual styles: assignment rows (orange dot, location +
  project link, event-overlay summary), gap rows (dashed outline, idle
  day count), and state_change rows (navy dot, friendly label, old →
  new value).  Active assignments get a green dot + "active" badge.
- Existing loadUnitHistory() and loadDeploymentHistory() functions kept
  as shims that delegate to loadDeploymentTimeline(), so modal-save
  callbacks that referenced them still trigger a refresh of the visible
  section.  Legacy function bodies preserved under _legacy_*_unused
  names for archeology; not called by anything.

Verified end-to-end:
- BE11529 timeline now shows 2 entries (active assignment with 24-event
  overlay + the deployed→benched state change), compared to the previous
  noisy mix that included 6 no-op state-change rows.
- Migration ran against real DB: 1 legacy row processed (had no
  project_id, marked deprecated without backfill).
- Assign / unassign / swap / edit now leave a paper trail in
  unit_history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 00:15:07 +00:00
serversdown 63bd6ad8a2 feat(sfm): project-level vibration events roll-up
Phase 3 of the SFM integration. Adds a "Project-wide vibration events"
KPI card to the Vibration tab of every project detail page, summarising
event activity across all of that project's vibration MonitoringLocations.

Backend:
- backend/services/sfm_events.py: vibration_summary_for_project() helper.
  Concurrently fans out events_for_location() across every vibration
  location in the project; aggregates total events, peak PVS (with the
  location it occurred at), last-event timestamp, false-trigger count;
  and produces a per-location breakdown sorted by event count.

- backend/routers/project_locations.py: new GET /api/projects/{p}/
  vibration_summary endpoint returning an HTML partial (HTMX-friendly,
  matches the locations-list HTMX pattern already used on this page).

Frontend:
- templates/partials/projects/vibration_summary.html: new partial with
  four KPI tiles (total, peak PVS + linked location + date, last event,
  false triggers) and a "Top locations by activity" mini-list showing
  the top 5 by event count.  Empty-state copy when the project has no
  vibration locations yet.

- templates/projects/detail.html: HTMX-load the new summary above the
  locations list inside the Vibration tab.

Verified against terra-view-alpha: 24 events across "Loc 1 - 78 poop
street", peak PVS 14.1351 in/s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 00:09:02 +00:00
serversdown bc5a151faa feat(sfm): per-unit event history with attribution + Unattributed bucket
Phase 2 of the SFM integration.  Adds a "SFM Events" section to the
seismograph unit detail page (/unit/{id}).  Every event SFM has for the
serial is shown, with each event annotated by which project/location
assignment window it falls into.  Events outside every assignment window
get the "⚠ Unattributed" badge plus a "<N>d before/after <nearest location>"
hint — that's the operator's signal that backdating an assignment (Phase 1
edit-pencil) will absorb the orphan events.

Backend:
- backend/services/sfm_events.py: new events_for_unit() helper.  Fetches
  all events for the serial via SFM /db/events (one call, ceiling 5000),
  loads every UnitAssignment for the unit + resolves MonitoringLocation +
  Project names, then annotates each event with attribution or
  nearest_assignment (signed delta_days).  Bucket filter: all /
  attributed / unattributed.  Stats always reflect the full event set so
  the "Unattributed" KPI tile is meaningful regardless of which bucket
  is being viewed.

- backend/routers/units.py: new GET /api/units/{unit_id}/events with
  bucket / date-range / false_trigger / limit query params.  404s on
  unknown unit_id; returns an empty payload for non-seismograph
  device_types so the page can render the section conditionally.

Frontend (templates/unit_detail.html):
- New "SFM Events" section between "Deployment History" and "Timeline",
  styled to match the existing card pattern (border-t divider, same
  heading weight).
- Hidden by default; revealed only when currentUnit.device_type ===
  'seismograph' after the unit data loads.
- Four KPI tiles: Total Events / Unattributed (highlighted amber when
  > 0) / Peak PVS / Last Event.
- Filters: Bucket (all|attributed|unattributed), From/To, False
  Triggers, Limit, + Refresh.
- Event table with Attribution column.  Attributed rows link to the
  project/location detail page; unattributed rows are tinted amber
  and show "<N>d before/after <nearest location>" with a link to the
  nearest location.
- Empty-state copy varies by bucket: e.g. unattributed-with-zero shows
  " All events for this unit are attributed to a project/location".

Verified end-to-end against BE11529 (81 events total, 24 attributed,
57 unattributed — all 57 unattributed events emitted within hours of
the assignment start, which means backdating the assignment by a day
would attribute every one of them).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 22:38:46 +00:00
serversdown 09db988a35 feat(sfm): editable UnitAssignment date windows (backdate deployments)
Operators couldn't change a unit's assigned_at / assigned_until after
creating the assignment, so a unit physically deployed in December 2025
but only recorded in terra-view today would show "deployed today" and
all its real events would be invisible on the project's location page.

Backend:
- PATCH /api/projects/{project_id}/assignments/{assignment_id}
  Accepts JSON body with optional assigned_at, assigned_until, notes.
  - assigned_at is required (cannot be cleared)
  - assigned_until can be null to mark active / indefinite
  - assigned_until must be after assigned_at
  - rejects overlaps with other assignments of the same unit at the
    same location (different units overlapping is fine — that's a
    legitimate swap window)
  - assignment.status flips to "active" when assigned_until is cleared,
    "completed" when set
  - 404 if the assignment doesn't belong to {project_id} (security)

Frontend (vibration_location_detail.html):
- Pencil icon next to each row in the "Seismographs deployed at this
  location" card. Click to open a modal with datetime-local inputs for
  From + Until (blank = active) and a Notes textarea. Save reloads the
  Events tab so KPI tiles and the event table reflect the new window.
- Helper line under the assignment list explains the workflow:
  "Click the pencil to backdate a deployment so historical events get
  attributed to this location."

Verified end-to-end against real data: backdating BE11529's assignment
on a vibration location from 2026-04-14 to 2025-12-01 surfaced 10
additional events (24 -> 34) that were previously invisible.

Validation suite (all returning correct HTTP codes):
  - assigned_until < assigned_at -> 400
  - cross-project assignment_id -> 404
  - assigned_at cleared -> 400
  - notes-only update -> 200

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 22:30:32 +00:00
serversdown df771a87de feat(sfm): wire SFM events into project-location detail page
Phase 1 of the SFM project/location integration. When viewing a vibration
monitoring location, operators now see the events that were actually
recorded there — fanned out across every seismograph that was ever
assigned to that location (handles mid-project unit swaps).

Backend:
- backend/services/sfm_events.py: new events_for_location() async helper.
  Walks UnitAssignment rows for the location (active + closed), intersects
  each assignment's [assigned_at, assigned_until] window with the requested
  filter, and concurrently queries SFM /db/events for each (serial, window)
  pair via httpx.AsyncClient.  Unions, sorts newest-first, computes summary
  stats (event count, peak PVS + when/who, last event, false-trigger count)
  over the full set, and trims to the user's display limit.  Over-fetches
  per-window (up to 5000) so stats stay accurate even with a small display
  limit.

- backend/routers/project_locations.py: new GET endpoint
  /api/projects/{project_id}/locations/{location_id}/events.  Validates
  project/location pairing (404 on mismatch).  SLM locations return an
  empty payload rather than 404 so the frontend can render gracefully.

Frontend:
- templates/vibration_location_detail.html: new "Events" tab on the
  location detail page.  KPI tiles (total / peak PVS / last event / false
  triggers), "Seismographs deployed at this location" assignment list
  (transparency: shows each assignment's date range and contributed event
  count), date / false-trigger / limit filters, and the paginated event
  table.  Lazy-loaded on first tab visit; manual refresh button.

Architectural notes:
- SFM remains the single source of truth for events.  No event sync; live
  HTTP per page load.
- UnitAssignment is the join key (not MonitoringSession).
- Events whose timestamp falls outside every assignment window are NOT
  surfaced here.  Those orphan events get a dedicated "Unattributed
  events" view on the per-unit detail page in Phase 2.

Out of scope (this commit):
- Phase 2 (per-unit history view) and Phase 3 (project-level roll-up)
  reuse this helper but ship separately.
- Phase 4 (deprecating deployment_records) is independent.
- Extracting the event-table JS to a shared file is a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:57:14 +00:00
9 changed files with 2389 additions and 20 deletions
@@ -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()
+316
View File
@@ -30,6 +30,7 @@ from backend.models import (
RosterUnit, RosterUnit,
MonitoringSession, MonitoringSession,
DataFile, DataFile,
UnitHistory,
) )
from backend.templates_config import templates from backend.templates_config import templates
from backend.utils.timezone import local_to_utc 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"]) 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 # Shared helpers
# ============================================================================ # ============================================================================
@@ -403,6 +440,13 @@ async def assign_unit_to_location(
) )
db.add(assignment) 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.commit()
db.refresh(assignment) db.refresh(assignment)
@@ -448,11 +492,164 @@ async def unassign_unit(
assignment.status = "completed" assignment.status = "completed"
assignment.assigned_until = datetime.utcnow() 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() db.commit()
return {"success": True, "message": "Unit unassigned successfully"} 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") @router.post("/locations/{location_id}/swap")
async def swap_unit_on_location( async def swap_unit_on_location(
project_id: str, project_id: str,
@@ -503,6 +700,16 @@ async def swap_unit_on_location(
if current: if current:
current.assigned_until = datetime.utcnow() current.assigned_until = datetime.utcnow()
current.status = "completed" 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 # Create new assignment
new_assignment = UnitAssignment( new_assignment = UnitAssignment(
@@ -516,6 +723,13 @@ async def swap_unit_on_location(
notes=notes, notes=notes,
) )
db.add(new_assignment) 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 # Update modem pairing on the seismograph if modem provided
if modem_id: 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) @router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
async def get_nrl_files( async def get_nrl_files(
project_id: str, project_id: str,
+100 -2
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from typing import Dict, Any from typing import Dict, Any, Optional
from backend.database import get_db from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
@@ -72,3 +72,101 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"slm_serial_number": unit.slm_serial_number, "slm_serial_number": unit.slm_serial_number,
"deployed_with_modem_id": unit.deployed_with_modem_id "deployed_with_modem_id": unit.deployed_with_modem_id
} }
@router.get("/units/{unit_id}/events")
async def get_unit_events(
unit_id: str,
bucket: str = Query("all", regex="^(all|attributed|unattributed)$"),
from_dt: Optional[datetime] = Query(None),
to_dt: Optional[datetime] = Query(None),
false_trigger: Optional[bool] = Query(None),
limit: int = Query(500, ge=1, le=5000),
db: Session = Depends(get_db),
):
"""
Return SFM events for a single unit, annotated with assignment attribution.
Each event includes an `attribution` object pointing at the project/location
it falls into (or null if outside every assignment window). Unattributed
events also carry a `nearest_assignment` field with `delta_days` so the
operator can see how far off the nearest assignment is — useful for
deciding whether to backdate the assignment to absorb the event.
Bucket filter:
- all (default): every event
- attributed: only events inside an assignment window
- unattributed: only orphan events (the diagnostic bucket)
Non-seismograph units return an empty events list. The route does not
404 for SLMs/modems so the unit detail page can render the section
conditionally without depending on the response shape.
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
if unit.device_type != "seismograph":
return {
"events": [],
"count": 0,
"stats": {
"event_count": 0,
"unattributed_count": 0,
"peak_pvs": None,
"peak_pvs_at": None,
"peak_pvs_serial": None,
"last_event": None,
"false_trigger_count": 0,
},
"assignments_total": 0,
"device_type": unit.device_type,
}
from backend.services.sfm_events import events_for_unit
result = await events_for_unit(
db,
unit_id,
bucket=bucket,
from_dt=from_dt,
to_dt=to_dt,
false_trigger=false_trigger,
limit=limit,
)
result["device_type"] = unit.device_type
return result
@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,
)
+256
View File
@@ -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,
}
+582
View File
@@ -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 %}
+11
View File
@@ -90,6 +90,17 @@
<!-- Vibration Locations sub-panel --> <!-- Vibration Locations sub-panel -->
<div id="vib-sub-locations" class="vib-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="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-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> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
+436 -18
View File
@@ -278,30 +278,106 @@
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p> <p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div> </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="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment History</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</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"> <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">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ↻ Refresh
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Log Deployment
</button> </button>
</div> </div>
<div id="deploymentHistory" class="space-y-3"> <div id="deploymentTimeline" class="space-y-3">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p> <p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
</div> </div>
</div> </div>
<!-- Unit History Timeline --> <!-- SFM Events (seismographs only) -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6"> <div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3> <div class="flex justify-between items-center mb-4">
<div id="historyTimeline" class="space-y-3"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">SFM Events</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p> <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>
</div> </div>
<!-- Photos --> <!-- Photos -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6"> <div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-start mb-4"> <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() { async function loadUnitHistory() {
if (typeof loadDeploymentTimeline === 'function') {
return loadDeploymentTimeline();
}
}
async function _legacy_loadUnitHistory_unused() {
try { try {
const response = await fetch(`/api/roster/history/${unitId}`); const response = await fetch(`/api/roster/history/${unitId}`);
if (!response.ok) { 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() { async function loadDeploymentHistory() {
if (typeof loadDeploymentTimeline === 'function') {
return loadDeploymentTimeline();
}
}
async function _legacy_loadDeploymentHistory_unused() {
try { try {
const res = await fetch(`/api/deployments/${unitId}`); const res = await fetch(`/api/deployments/${unitId}`);
const data = await res.json(); const data = await res.json();
@@ -1884,10 +1975,337 @@ loadCalibrationInterval();
setupCalibrationAutoCalc(); setupCalibrationAutoCalc();
loadUnitData().then(() => { loadUnitData().then(() => {
loadPhotos(); loadPhotos();
loadUnitHistory(); loadDeploymentTimeline();
loadDeploymentHistory(); 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _ueAttrCell(ev) {
const a = ev.attribution;
if (a) {
// Attributed: project / location link.
const projLabel = _ueEsc(a.project_name || '—');
const locLabel = _ueEsc(a.location_name || '—');
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
class="text-seismo-orange hover:text-seismo-navy"
title="${projLabel}${locLabel}">
📍 ${locLabel}
</a>
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
}
// Unattributed: show nearest assignment + delta for context.
const n = ev.nearest_assignment;
if (n) {
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
const days = Math.abs(n.delta_days);
const daysLabel = days < 1
? `<${(days * 24).toFixed(1)}h`
: `${days.toFixed(1)}d`;
return `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
</div>`;
}
return `<span class="px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">⚠ No assignments</span>`;
}
function renderUnitEventTable(events, total, container, bucket, assignmentsTotal) {
if (!events || events.length === 0) {
let msg;
if (bucket === 'unattributed') {
msg = assignmentsTotal === 0
? 'No assignments yet — every event from this unit is unattributed. Assign it to a project location to start attributing events.'
: '✅ All events for this unit are attributed to a project/location.';
} else if (bucket === 'attributed') {
msg = assignmentsTotal === 0
? 'No assignments yet for this unit.'
: 'No events recorded inside any assignment window with the current filter.';
} else {
msg = 'No events found for this unit with the current filter.';
}
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
return;
}
const rows = events.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = _ueFmtPPV(ev.tran_ppv);
const vert = _ueFmtPPV(ev.vert_ppv);
const lng = _ueFmtPPV(ev.long_ppv);
const pvs = _ueFmtPPV(ev.peak_vector_sum);
const ft = ev.false_trigger
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
: '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}">
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.long_ppv)}">${lng}</td>
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_uePpvClass(ev.peak_vector_sum)}">${pvs}</td>
<td class="px-4 py-2.5 text-sm">${ft}</td>
<td class="px-4 py-2.5 text-sm">${_ueAttrCell(ev)}</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3 pb-1">Showing ${events.length} of ${total.toLocaleString()} event${total === 1 ? '' : 's'}</div>
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
}
// ===== Pair Device Modal Functions ===== // ===== Pair Device Modal Functions =====
let pairModalModems = []; // Cache loaded modems let pairModalModems = []; // Cache loaded modems
let pairModalDeviceType = ''; // Current device type let pairModalDeviceType = ''; // Current device type
+403
View File
@@ -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"> class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
Overview Overview
</button> </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')" <button onclick="switchTab('settings')"
data-tab="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"> 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>
</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 --> <!-- Settings Tab -->
<div id="settings-tab" class="tab-panel hidden"> <div id="settings-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <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.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
button.classList.add('border-seismo-orange', 'text-seismo-orange'); 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
// Location settings form submission // Location settings form submission