7 Commits

Author SHA1 Message Date
serversdown b1c2a1d778 feat(projects): "Merge into…" button to consolidate duplicate projects
Operator-facing tool for cleaning up duplicate projects.  Common after
the metadata-backfill parser auto-creates near-duplicates from operator
name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
"Trumbull-Brayman-Mont Dam", etc.).

Workflow: visit the duplicate project's detail page, click "Merge into…"
in the header, search for the canonical target project from a typeahead,
review the preview (what assignments / locations / sessions will move,
any conflicts), confirm.  Source is soft-deleted; everything else
re-points to the target.  Smart consolidation: same-named locations in
both projects merge into one (source's assignments move to target's
existing location with the same name; source's empty location is then
deleted).  Different-named locations move as-is.

Backend:
- backend/services/project_merge.py (new): preview() and execute()
  functions.  Transaction-safe.  Per-assignment UnitHistory audit row
  with change_type='assignment_merged' so the deployment timeline shows
  the merge.  Source modules disabled; missing modules added to target.
  Handles edge cases: same project_id rejected, deleted projects rejected,
  orphan project-direct assignments (no location) re-pointed defensively.

- backend/routers/projects.py: new endpoints
    GET  /api/projects/{source_id}/merge_preview?target_id=...
    POST /api/projects/{source_id}/merge_into?target_id=...

Frontend (templates/partials/projects/project_header.html):
- "Merge into…" button in Project Actions area.
- Modal with typeahead (reuses /api/admin/metadata_backfill/projects_search)
  scoped to existing projects only (no create-new option).  Filters out
  the source project from candidates so operator can't accidentally pick
  it as target.
- Preview pane shows totals + per-location plan (consolidate vs move) +
  warnings (mismatched client names, location consolidation note).
- Red "Merge (permanent)" confirm button only enables after a target is
  picked and preview loads.
- On success, browser redirects to target project page.

Smoke verified: "Swank-Karns Crossing" (1 assignment) merged into
"Swank-Karns Crossings"; target now has 2 locations + 2 assignments,
source has 0 dangling rows, 1 project_merge audit entry written.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:18:42 +00:00
serversdown d3b5a3fd26 feat(sfm): inline typeahead override of project + location on each cluster card
Operator no longer has to accept the parser's suggested project /
location verbatim.  Each cluster card now has editable typeahead inputs
that search existing projects (and existing locations within the chosen
project), with a "Create new: <typed>" fallback always available.

Solves the I-80-North-Fork case: of the 20+ cluster variants
("I-80-North Fork Bridges-I80 E. Abutment", "I-80- North Fork
Bridges-543 Plank Rd", etc.), operator types "I-80" in the Project
input, picks the existing project from the dropdown, and the cluster
attaches to it.  Repeat for the other variants.  No need to pre-create
the canonical project — though pre-creation still works fine if you'd
rather.

Backend (backend/routers/metadata_backfill.py):
- GET /api/admin/metadata_backfill/projects_search?q=&limit=
  Returns existing projects matching by case-insensitive substring OR
  rapidfuzz WRatio score >= 0.50.  Substring matches sort to the top
  (treated as exact for ordering).  Includes location_count and
  project_number/client_name in each result for disambiguation.  Always
  emits a "Create new: <q>" suggestion alongside the matches.

- GET /api/admin/metadata_backfill/locations_search?project_id=&q=&limit=
  Same shape, scoped to a single project's vibration locations.

- POST /api/admin/metadata_backfill/apply now accepts four override
  keys per cluster (was previously two):
    project_id       → attach to existing Project (operator picked from
                       typeahead)
    project_name     → create new with this name (operator typed a
                       custom name; existing project_name behaviour)
    location_id      → attach to existing MonitoringLocation; validated
                       against the chosen project_id so a stale location
                       FK can't sneak in
    location_name    → create new location with this name

Frontend (templates/admin/metadata_backfill.html):
- Each non-blank-meta cluster card now has two editable typeahead inputs
  (Project + Location) pre-populated with the parser's suggested
  values.  Old static "Project: + Create new: X" / "≈ Fuzzy match" pills
  replaced with compact hint lines under the inputs showing what the
  current value will do.
- Typeahead dropdown opens on focus, debounced 150ms on type.  Shows
  matched existing entities with score badges (exact / NN%) plus a
  "Create new: <typed>" option at the bottom.  Click-to-pick fills the
  text input and writes the entity id into a hidden field.
- Picking a new project clears the location id (forces re-pick under
  the new project, avoids cross-project location FKs).
- _gatherOverrides re-wired to emit the new project_id / location_id
  keys when the operator picked from the dropdown, falling back to
  *_name when they typed free-form.

Backward-compatible: blank-meta clusters keep their existing "project_name
/ location_name" plain inputs and the override path still honours them.

Verified end-to-end:
- /projects_search?q=I-80 returns the existing "I-80 - North Fork
  Bridge" project (score 1.0, has 4 locations) plus a "Create new"
  option.
- /locations_search requires project_id (400 without it).
- Wizard page renders with typeahead wiring confirmed in HTML.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 19:48:09 +00:00
serversdown d46f9fccf8 fix(sfm): broaden Loc-N suffix regex to catch '.Loc' and 'Loc No.' variants
Operators use more separator variations than the original regex caught:
  - "Trumbull-Brayman-JV- Mont.Dam.Loc 2-R-25" — period as separator
  - "CMU - RKM Hall - Loc No. 3 - 4615 Forbes" — "No." between Loc and digit

Added period to the separator character class and optional "No." token
before the digit.  Catches both above patterns plus near-variants
without false-positives on normal project strings.

Real-data impact: 5 more clusters now auto-strip cleanly, including the
1,903-event Trumbull-Brayman-JV- Mont.Dam cluster.  Confidence
distribution: 43 → 44 high.
2026-05-12 19:19:46 +00:00
serversdown 6ebbe28308 feat(sfm): strip "- Loc N" suffix from operator-typed project names
Operators sometimes bake location identifiers into the project string
for email-readability — "Fay - Locks & Dam No3 - Loc 2 - 735 Bunola"
where "Fay - Locks & Dam No3" is the actual project and "- Loc 2 -
735 Bunola" is location info that already lives in sensor_location.
Without stripping, every "- Loc N" variant became a separate project,
fragmenting what should be one project with several locations.

Backend:
- New _extract_project_root() helper.  Regex matches " - Loc N" / "-Loc3" /
  " - Location #5" / etc. with case-insensitive multi-dash support; strips
  from that marker forward and cleans up dangling separators.  Strings
  without a Loc-marker pass through unchanged.

- Cluster dataclass adds project_root field alongside project_raw.
  project_raw stays the operator-typed string for display ("hover to see
  what was actually typed").  project_root is what gets normalised for
  matching and used as the suggested project name.

- _ensure_project + _ensure_location now do normalisation-aware dedup
  before creating: a cluster of "SR81" and a cluster of "SR 81" (which
  normalise to the same string) collapse into one project on apply,
  even when applied in the same bulk operation.  Avoids UNIQUE
  constraint collisions and duplicate-named-by-spacing projects.

Frontend:
- Wizard cluster cards show "↳ stripped trailing 'Loc N' suffix; operator
  typed: <raw>" when project_root differs from project_raw, so the
  operator can see at a glance what the parser did to the string.

Real-data results: against the same 10,055 SFM events, confidence
distribution improved from 37/14/8 (high/med/low) to 43/9/7.  "Fay -
Locks & Dam No3" now appears as ONE project across 6 cluster instances
spanning 3 serials and 6 different locations — exactly the
"one project, many locations" model the user described.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:49:14 +00:00
serversdown 42de06f441 feat(sfm): Phase 5a — bulk-backfill projects/locations/assignments from event metadata
Operator clicks one button.  Parser reads SFM's events table (operator-typed
project / client / sensor_location strings), clusters by serial + time +
metadata, fuzzy-matches against existing projects, and proposes
Project / MonitoringLocation / UnitAssignment chains to create.
Auto-applies high-confidence non-conflicting clusters in bulk; queues
medium/low confidence for individual review.

Verified against real data: 10,052 events → 59 clusters → 37 high-
confidence + 14 medium + 8 low.  Test-applied one cluster end-to-end;
Project + Module + Location + Assignment + UnitHistory + Decision rows
all created correctly, and Phase 2's attribution walk picked up the
events automatically on the new location's detail page.

Pipeline (backend/services/metadata_backfill.py, ~700 lines):
  1. Pull all SFM events via /db/events per serial.
  2. Pre-filter: drop events already covered by an existing UnitAssignment
     window (Phase 2 handles those automatically).
  3. Time-cluster what's left: serial + 7-day gap is the cluster identity.
  4. Metadata-split each time-cluster on persistent metadata transitions
     (≥ 2 consecutive events) so a single typo doesn't fork the cluster.
  5. Match against existing graph (rapidfuzz.WRatio multi-signal scoring,
     normalisation that handles abbreviations / reorders / separator
     variations).  Thresholds: 0.95 exact, 0.80 fuzzy, min-shorter-input
     5 chars to guardrail false positives on single common words.
  6. Score confidence (high/medium/low) using event count, span,
     blank-meta, conflict, ambiguity rules.
  7. Detect conflicts: overlap with existing UnitAssignment at a different
     location for the same serial → blocking.  Operator must reconcile.
  8. Apply: ensure auto_imported ProjectType exists, ensure
     vibration_monitoring ProjectModule on the project, write
     Project / MonitoringLocation / UnitAssignment / UnitHistory all in
     one transaction.

Migration (backend/migrate_add_metadata_backfill.py): adds
unit_assignments.source column (default 'manual') and
metadata_backfill_decisions table.  Idempotent, non-destructive.

API (backend/routers/metadata_backfill.py):
  GET  /api/admin/metadata_backfill/scan          — clusters + suggestions
  POST /api/admin/metadata_backfill/apply         — bulk apply by cluster_ids
                                                     w/ optional per-cluster
                                                     project/location overrides
  POST /api/admin/metadata_backfill/skip          — mark skipped (persistent)

UI (templates/admin/metadata_backfill.html, accessible at
/settings/developer/metadata-backfill via the Developer tab of Settings):
  - One-button "Run scan" entry.
  - Summary KPI tiles (scanned / already attributed / pending / conflicts).
  - "Apply all high-confidence" bulk button at the top — primary path.
  - Per-cluster cards below with Apply / Skip / Preview event actions.
  - Blank-meta clusters get inline input fields for operator-typed project +
    location names before applying.
  - Blocking-conflict clusters render with the conflicting assignment
    information and a disabled Apply button.
  - Live progress toast during apply.
  - Reuses the Phase 1+2+4 event-detail modal for "Preview event" — operator
    can sanity-check the BW report data against the cluster's sample event.

Dependencies: rapidfuzz==3.10.1 added to requirements.txt.  Pre-built C
wheels for all platforms, ~5s docker build hit.

Phase 5b (deferred to next session): swap-detection daily background job,
notification inbox for auto-applied swaps, recently-applied audit view,
"Tidy" page for renaming/merging auto-created projects.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 05:54:57 +00:00
serversdown 21844b4d65 feat(sfm): download buttons in event-detail modal (Blastware binary + sidecar JSON)
Two new action buttons at the top of the Source File section of the
event-detail modal:

1. Download Blastware file — primary orange button.  Pulls the raw .AB0
   /.G10/.6R0/etc. binary from SFM (/db/events/{id}/blastware_file) via
   terra-view's /api/sfm proxy.  The browser saves it with the original
   on-disk filename (using the HTML5 `download` attribute pointed at
   sidecar.blastware.filename).  Operator can then open the file
   directly in Blastware on a Windows box for full waveform analysis,
   archive it, or attach it to a compliance report.

   Greyed-out "Blastware file unavailable" placeholder shown when
   sidecar.blastware.available is false (rare — would mean SFM stored
   the metadata but lost the binary).

2. Download sidecar JSON — secondary outlined button.  Pulls the same
   .sfm.json the modal renders from.  Saved as <binary>.sfm.json.
   Useful for ops/diagnostics and for the future metadata-driven
   project parser (Phase 5) which can chew on these directly.

End-to-end verified through the proxy: 8882-byte Blastware binary
intact with "Instantel" magic header preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 04:05:28 +00:00
serversdown 80fa76208a feat(sfm): shared event-detail modal with rich BW report fields
Clicking any event row in any of the three event tables (/sfm Events,
project-location Events tab, unit detail SFM Events) now opens a modal
populated from the SFM .sfm.json sidecar.  Previously the /sfm page had
a basic inline modal showing only the columns already in the table;
this rebuilds it as a shared component and exposes the rich fields
that the BW ASCII report unlocks.

Shared component:
- backend/static/event-modal.js — single ~250-line module.  Public API:
  showEventDetail(eventId) fetches /api/sfm/db/events/{id}/sidecar
  live (no extra terra-view caching) and renders sections for:
    • Event (serial, timestamp, record type, sample rate, rec time,
      waveform key)
    • Project Info (operator-typed user notes — project / client /
      operator / sensor_location — flagged in the UI as "as typed
      into the seismograph at session start", not the terra-view
      assignment)
    • Peak Particle Velocity (per-channel + vector sum, with the
      time-of-vector-sum-peak when bw_report is available)
    • Microphone (Peak dB(L) + psi, ZC frequency, time of peak)
    • Sensor Self-Check table (per-channel freq + ratio/amplitude +
      pass/fail)
    • Device & Recording Metadata (firmware, battery, calibration
      date + by-whom, geo range, stop mode, units)
    • Source File (Blastware filename, size, SHA-256, capture time)
  closeEventDetailModal() closes; Escape key also closes.

- templates/partials/event_detail_modal.html — modal shell partial
  (sticky title bar, scrollable body, click-outside-to-close).

Wired into three pages:
- templates/sfm.html: removed the old inline modal + showEventDetail /
  ppvCard / closeEventModal functions (replaced by the shared module).
  Row onclick now passes just the event id instead of the full JSON.
- templates/vibration_location_detail.html: row click on the Events
  tab opens the modal.  The /unit/{serial} link inside the row has
  event.stopPropagation() so the link navigates instead of opening
  the modal.
- templates/unit_detail.html: row click on the SFM Events table opens
  the modal.  The attribution-cell project/location links also got
  stopPropagation.

Graceful degradation: older events forwarded before the watcher's
_ASCII.TXT pairing fix don't have a bw_report block in their sidecar.
The modal renders an amber banner explaining that and shows just the
event + project_info + peak_values + source-file sections.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 03:55:41 +00:00
16 changed files with 3463 additions and 72 deletions
+11
View File
@@ -115,6 +115,10 @@ app.include_router(scheduler.router)
from backend.routers import report_templates from backend.routers import report_templates
app.include_router(report_templates.router) app.include_router(report_templates.router)
# Metadata-backfill admin router (Phase 5a)
from backend.routers import metadata_backfill
app.include_router(metadata_backfill.router)
# Alerts router # Alerts router
from backend.routers import alerts from backend.routers import alerts
app.include_router(alerts.router) app.include_router(alerts.router)
@@ -240,6 +244,13 @@ async def sfm_page(request: Request):
return templates.TemplateResponse("sfm.html", {"request": request}) return templates.TemplateResponse("sfm.html", {"request": request})
@app.get("/settings/developer/metadata-backfill", response_class=HTMLResponse)
async def metadata_backfill_wizard_page(request: Request):
"""Wizard for auto-creating projects/locations/assignments from
operator-typed BW event metadata (Phase 5a)."""
return templates.TemplateResponse("admin/metadata_backfill.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse) @app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request): async def modems_page(request: Request):
"""Field modems management dashboard""" """Field modems management dashboard"""
+94
View File
@@ -0,0 +1,94 @@
"""
Migration: add metadata-backfill support.
Adds:
1. `unit_assignments.source` column (TEXT, default 'manual').
Lets us audit which assignments were created by the metadata-backfill
parser vs by a human, and bulk-undo parser actions if needed.
2. `metadata_backfill_decisions` table. Tracks operator decisions per
cluster_id so the wizard remembers what's been skipped, what's
been applied, and what's pending across re-scans.
Idempotent — safe to re-run.
Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_metadata_backfill.py
"""
import os
import sqlite3
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)
cur = conn.cursor()
# ── 1. unit_assignments.source column ──────────────────────────────────
cur.execute("PRAGMA table_info(unit_assignments)")
cols = {row[1] for row in cur.fetchall()}
if "source" not in cols:
print("Adding unit_assignments.source column (default 'manual') ...")
cur.execute(
"ALTER TABLE unit_assignments ADD COLUMN source TEXT DEFAULT 'manual'"
)
# Backfill: any existing row gets source='manual'
cur.execute("UPDATE unit_assignments SET source='manual' WHERE source IS NULL")
conn.commit()
print(" Done.")
else:
print("unit_assignments.source already exists — skipping")
# ── 2. metadata_backfill_decisions table ──────────────────────────────
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='metadata_backfill_decisions'"
)
if cur.fetchone() is None:
print("Creating metadata_backfill_decisions table ...")
cur.execute("""
CREATE TABLE metadata_backfill_decisions (
cluster_id TEXT PRIMARY KEY, -- deterministic hash
status TEXT NOT NULL, -- pending | applied | skipped | conflict
confidence TEXT NOT NULL, -- high | medium | low (at time of decision)
decided_at TEXT, -- when applied/skipped
decided_by TEXT, -- 'background' | 'operator' | 'auto-high'
applied_assignment_id TEXT, -- FK to unit_assignments (if applied)
notes TEXT,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
serial TEXT NOT NULL,
project_raw TEXT,
location_raw TEXT,
first_event_ts TEXT,
last_event_ts TEXT,
event_count INTEGER NOT NULL DEFAULT 0
)
""")
cur.execute(
"CREATE INDEX idx_mbd_status ON metadata_backfill_decisions(status)"
)
cur.execute(
"CREATE INDEX idx_mbd_last_seen ON metadata_backfill_decisions(last_seen_at)"
)
cur.execute(
"CREATE INDEX idx_mbd_serial ON metadata_backfill_decisions(serial)"
)
conn.commit()
print(" Done.")
else:
print("metadata_backfill_decisions table already exists — skipping")
conn.close()
print("\nMigration complete.")
if __name__ == "__main__":
migrate_database()
+39
View File
@@ -259,9 +259,48 @@ class UnitAssignment(Base):
device_type = Column(String, nullable=False) # "slm" | "seismograph" device_type = Column(String, nullable=False) # "slm" | "seismograph"
project_id = Column(String, nullable=False, index=True) # FK to Project.id project_id = Column(String, nullable=False, index=True) # FK to Project.id
# Provenance: how was this assignment created? Used for auditing,
# bulk-undo of parser actions, and the Phase 4 deployment timeline.
# "manual" — operator created via UI
# "metadata_backfill" — auto-created by the metadata parser
# from operator-typed BW event metadata
# (bulk backfill workflow)
# "metadata_backfill_swap" — auto-created by swap-detection
# background job
source = Column(String, nullable=False, default="manual")
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
class MetadataBackfillDecision(Base):
"""
Per-cluster decisions tracked by the metadata-backfill parser.
`cluster_id` is the deterministic SHA1 hash of
(serial, first_event_date, last_event_date), so the same cluster
produces the same id across re-scans. The decisions table lets the
parser remember "I already applied this" or "operator skipped this"
across scan invocations.
"""
__tablename__ = "metadata_backfill_decisions"
cluster_id = Column(String, primary_key=True)
status = Column(String, nullable=False) # pending | applied | skipped | conflict
confidence = Column(String, nullable=False) # high | medium | low
decided_at = Column(DateTime, nullable=True)
decided_by = Column(String, nullable=True) # background | operator | auto-high
applied_assignment_id = Column(String, nullable=True) # FK to unit_assignments.id
notes = Column(Text, nullable=True)
first_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
serial = Column(String, nullable=False, index=True)
project_raw = Column(String, nullable=True)
location_raw = Column(String, nullable=True)
first_event_ts = Column(DateTime, nullable=True)
last_event_ts = Column(DateTime, nullable=True)
event_count = Column(Integer, nullable=False, default=0)
class ScheduledAction(Base): class ScheduledAction(Base):
""" """
Scheduled actions: automation for recording start/stop/download. Scheduled actions: automation for recording start/stop/download.
+394
View File
@@ -0,0 +1,394 @@
"""
Metadata-backfill admin router.
Endpoints under /api/admin/metadata_backfill:
GET /scan — run the scan; return clusters + suggestions (JSON).
Cached 5 minutes so the wizard doesn't re-scan on
every page render.
POST /apply — apply a list of cluster_ids; body specifies which to
accept and optional per-cluster overrides.
POST /skip — mark cluster_ids as skipped (won't reappear).
"""
from __future__ import annotations
import os
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Project, MonitoringLocation
from backend.services import metadata_backfill as svc
router = APIRouter(prefix="/api/admin/metadata_backfill", tags=["metadata-backfill"])
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
# In-process scan cache. Trades memory for not re-hammering SFM on every
# wizard render. TTL: 5 minutes. Singleton per-process; fine for a
# single-worker uvicorn dev setup. For prod multi-worker we'd want to put
# this in the DB or Redis; deferred.
_SCAN_CACHE: dict = {"at": 0.0, "result": None}
_SCAN_CACHE_TTL_SECONDS = 300.0
def _serialise_suggestion(s: svc.Suggestion) -> dict:
c = s.cluster
return {
"cluster_id": c.cluster_id,
"serial": c.serial,
"first_event_ts": c.first_event_ts.isoformat(),
"last_event_ts": c.last_event_ts.isoformat(),
"event_count": c.event_count,
"sample_event_id": c.sample_event_id,
"project_raw": c.project_raw,
"project_root": c.project_root,
"location_raw": c.location_raw,
"client_raw": c.client_raw,
"operator_raw": c.operator_raw,
"is_blank_meta": c.is_blank_meta,
"metadata_consistency": c.metadata_consistency,
"project_match": s.project_match,
"project_existing_id": s.project_existing_id,
"project_existing_name": s.project_existing_name,
"project_match_score": s.project_match_score,
"project_suggested_name": s.project_suggested_name,
"location_match": s.location_match,
"location_existing_id": s.location_existing_id,
"location_existing_name": s.location_existing_name,
"location_match_score": s.location_match_score,
"location_suggested_name": s.location_suggested_name,
"proposed_assigned_at": s.proposed_assigned_at.isoformat(),
"proposed_assigned_until": s.proposed_assigned_until.isoformat() if s.proposed_assigned_until else None,
"confidence": s.confidence,
"blocking_conflict": s.blocking_conflict,
"conflicts": [
{
"existing_assignment_id": cf.existing_assignment_id,
"other_location_id": cf.other_location_id,
"other_location_name": cf.other_location_name,
"other_project_id": cf.other_project_id,
"other_project_name": cf.other_project_name,
}
for cf in s.conflicts
],
}
@router.get("/scan")
async def scan(
force: bool = False,
db: Session = Depends(get_db),
):
"""Run a scan and return clusters + suggestions.
Set force=true to bypass the 5-minute cache.
"""
now = time.time()
if not force and _SCAN_CACHE["result"] is not None \
and (now - _SCAN_CACHE["at"]) < _SCAN_CACHE_TTL_SECONDS:
return _SCAN_CACHE["result"]
result = await svc.scan_clusters_and_build_suggestions(db, SFM_BASE_URL)
# Group suggestions for the wizard UI.
by_confidence = {"high": [], "medium": [], "low": []}
blocking_conflict_count = 0
for s in result.suggestions:
by_confidence[s.confidence].append(_serialise_suggestion(s))
if s.blocking_conflict:
blocking_conflict_count += 1
payload = {
"scanned_event_count": result.scanned_event_count,
"cluster_count": result.cluster_count,
"already_attributed": result.already_attributed,
"skipped_orphans": result.skipped_orphans,
"pending_count": len(result.suggestions),
"blocking_conflict_count": blocking_conflict_count,
"by_confidence": {
"high": by_confidence["high"],
"medium": by_confidence["medium"],
"low": by_confidence["low"],
},
"scanned_at": now,
}
_SCAN_CACHE["result"] = payload
_SCAN_CACHE["at"] = now
return payload
@router.post("/apply")
async def apply(
request: Request,
db: Session = Depends(get_db),
):
"""Apply a list of clusters.
Body:
{
"cluster_ids": ["abc...", "def..."],
"overrides": { "abc...": { "project_name": "...", "location_name": "..." } }
}
To accept ALL non-conflict suggestions in one shot, the UI sends every
pending cluster_id with no overrides.
"""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
cluster_ids = body.get("cluster_ids") or []
overrides = body.get("overrides") or {}
if not isinstance(cluster_ids, list) or not cluster_ids:
raise HTTPException(status_code=400, detail="cluster_ids must be a non-empty list")
# Re-scan to get current suggestions. We don't trust the cached scan
# blindly — the operator might have manually created projects in
# between scan and apply.
scan_result = await svc.scan_clusters_and_build_suggestions(db, SFM_BASE_URL)
suggestions_by_id = {s.cluster.cluster_id: s for s in scan_result.suggestions}
selected: list[svc.Suggestion] = []
not_found: list[str] = []
for cid in cluster_ids:
s = suggestions_by_id.get(cid)
if s is None:
not_found.append(cid)
continue
# Apply overrides. Per-cluster overrides take precedence over the
# parser's suggested match. Four override fields supported:
# project_id — attach to an existing Project (operator picked
# from the typeahead)
# project_name — create new project with this name (operator
# typed a custom name not matching anything)
# location_id — attach to an existing MonitoringLocation
# location_name — create new location with this name
# project_id + location_id pairings: location_id is only honored
# if its project_id matches the chosen project (otherwise treated
# as a create-new).
ov = overrides.get(cid) or {}
if ov.get("project_id"):
target_id = ov["project_id"]
existing = db.query(svc.Project).filter_by(id=target_id).first()
if existing is not None:
s.project_existing_id = existing.id
s.project_existing_name = existing.name
s.project_suggested_name = existing.name
s.project_match = "exact"
else:
# Stale ID — treat as create_new with the cluster's typed name.
s.project_existing_id = None
s.project_match = "create_new"
elif "project_name" in ov:
new_name = (ov["project_name"] or "").strip()
if new_name:
s.project_suggested_name = new_name
s.project_existing_id = None
s.project_existing_name = None
s.project_match = "create_new"
if ov.get("location_id"):
target_id = ov["location_id"]
existing = db.query(svc.MonitoringLocation).filter_by(id=target_id).first()
# Only attach if the location belongs to the (now chosen) project.
chosen_project_id = s.project_existing_id
if existing is not None and (
chosen_project_id is None or existing.project_id == chosen_project_id
):
s.location_existing_id = existing.id
s.location_existing_name = existing.name
s.location_suggested_name = existing.name
s.location_match = "exact"
else:
s.location_existing_id = None
s.location_match = "create_new"
elif "location_name" in ov:
new_name = (ov["location_name"] or "").strip()
if new_name:
s.location_suggested_name = new_name
s.location_existing_id = None
s.location_existing_name = None
s.location_match = "create_new"
selected.append(s)
apply_result = svc.apply_suggestions(db, selected, decided_by="operator")
# Invalidate the scan cache so the next /scan picks up the new state.
_SCAN_CACHE["at"] = 0.0
_SCAN_CACHE["result"] = None
return {
"applied": apply_result.applied,
"failed": [{"cluster_id": cid, "reason": r} for cid, r in apply_result.failed],
"not_found": not_found,
"project_ids_created": apply_result.project_ids_created,
"location_ids_created": apply_result.location_ids_created,
"assignment_ids_created": apply_result.assignment_ids_created,
}
@router.post("/skip")
async def skip(
request: Request,
db: Session = Depends(get_db),
):
"""Mark cluster_ids as skipped — they won't reappear in future scans."""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
cluster_ids = body.get("cluster_ids") or []
if not isinstance(cluster_ids, list):
raise HTTPException(status_code=400, detail="cluster_ids must be a list")
n = svc.skip_clusters(db, cluster_ids, decided_by="operator")
_SCAN_CACHE["at"] = 0.0
_SCAN_CACHE["result"] = None
return {"skipped": n}
@router.get("/projects_search")
def projects_search(
q: str = "",
limit: int = 10,
db: Session = Depends(get_db),
):
"""Typeahead search of existing projects for the wizard's per-cluster
override inputs. Combines case-insensitive substring match with
rapidfuzz scoring so partial typing and slight typos both surface
candidates. Always returns a 'Create new' option at the end so the
operator can confirm they want to create rather than match.
Returns:
{
"matches": [
{"id": "...", "name": "...", "score": 0.91, "location_count": 3},
...
],
"create_new": {"label": "Create new: \"<q>\""}
}
"""
q_clean = (q or "").strip()
q_norm = svc._normalise(q_clean)
projects = (
db.query(Project)
.filter(Project.status != "deleted")
.all()
)
scored: list[tuple[Project, float]] = []
for p in projects:
p_norm = svc._normalise(p.name)
if not q_norm:
# Empty query → return top projects by latest activity
# (cheap heuristic: keep them all and sort by name).
scored.append((p, 0.0))
continue
# Cheap substring boost: if the normalised query is a substring,
# treat that as 1.0 regardless of WRatio.
if q_norm in p_norm:
scored.append((p, 1.0))
continue
score = svc.similarity(q_norm, p_norm)
if score >= 0.50: # surfacing threshold; not the match threshold
scored.append((p, score))
# Sort: score desc, then name asc.
scored.sort(key=lambda t: (-t[1], t[0].name.lower()))
scored = scored[:limit]
# Compute location counts in one batch query.
loc_counts: dict[str, int] = {}
if scored:
from sqlalchemy import func
ids = [p.id for p, _ in scored]
rows = (
db.query(MonitoringLocation.project_id, func.count(MonitoringLocation.id))
.filter(MonitoringLocation.project_id.in_(ids))
.group_by(MonitoringLocation.project_id)
.all()
)
loc_counts = {pid: cnt for pid, cnt in rows}
return {
"matches": [
{
"id": p.id,
"name": p.name,
"project_number": p.project_number,
"client_name": p.client_name,
"score": round(score, 3),
"location_count": loc_counts.get(p.id, 0),
}
for p, score in scored
],
"create_new": {"label": f'Create new: "{q_clean}"' if q_clean else None},
}
@router.get("/locations_search")
def locations_search(
project_id: str,
q: str = "",
limit: int = 10,
db: Session = Depends(get_db),
):
"""Typeahead search of existing locations within a project."""
if not project_id:
raise HTTPException(status_code=400, detail="project_id required")
q_clean = (q or "").strip()
q_norm = svc._normalise(q_clean)
locations = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == project_id)
.filter(MonitoringLocation.location_type == "vibration")
.all()
)
scored: list[tuple[MonitoringLocation, float]] = []
for l in locations:
l_norm = svc._normalise(l.name)
if not q_norm:
scored.append((l, 0.0))
continue
if q_norm in l_norm:
scored.append((l, 1.0))
continue
score = svc.similarity(q_norm, l_norm)
if score >= 0.50:
scored.append((l, score))
scored.sort(key=lambda t: (-t[1], t[0].name.lower()))
scored = scored[:limit]
return {
"matches": [
{
"id": l.id,
"name": l.name,
"address": l.address,
"score": round(score, 3),
}
for l, score in scored
],
"create_new": {"label": f'Create new: "{q_clean}"' if q_clean else None},
}
+66
View File
@@ -688,6 +688,72 @@ async def restore_project(project_id: str, db: Session = Depends(get_db)):
return {"success": True, "message": f"Project '{project.name}' restored."} return {"success": True, "message": f"Project '{project.name}' restored."}
# ── Project merge ──────────────────────────────────────────────────────────────
# Consolidate a duplicate project into another. Common after the
# metadata-backfill parser creates near-duplicate projects from name
# variations operators typed on the BW device.
# See backend/services/project_merge.py for the merge logic.
@router.get("/{source_id}/merge_preview")
async def project_merge_preview(
source_id: str,
target_id: str,
db: Session = Depends(get_db),
):
"""Preview what the merge will do — used by the confirmation modal.
No writes."""
from backend.services import project_merge as pm
preview = pm.preview(db, source_id, target_id)
return {
"source_project_id": preview.source_project_id,
"source_project_name": preview.source_project_name,
"target_project_id": preview.target_project_id,
"target_project_name": preview.target_project_name,
"total_assignments_moving": preview.total_assignments_moving,
"total_sessions_moving": preview.total_sessions_moving,
"total_data_files_moving": preview.total_data_files_moving,
"modules_to_add": preview.modules_to_add,
"warnings": preview.warnings,
"location_plans": [
{
"source_id": p.source_id,
"source_name": p.source_name,
"target_id": p.target_id,
"target_name": p.target_name,
"action": p.action,
"assignments_moving": p.assignments_moving,
"sessions_moving": p.sessions_moving,
}
for p in preview.location_plans
],
}
@router.post("/{source_id}/merge_into")
async def project_merge_execute(
source_id: str,
target_id: str,
db: Session = Depends(get_db),
):
"""Execute the merge. Source project gets soft-deleted; all its
locations / assignments / sessions / data_files / modules move to
the target. Same-named locations consolidate."""
from backend.services import project_merge as pm
result = pm.execute(db, source_id, target_id, decided_by="operator")
return {
"success": True,
"source_project_id": result.source_project_id,
"target_project_id": result.target_project_id,
"assignments_moved": result.assignments_moved,
"locations_moved": result.locations_moved,
"locations_consolidated": result.locations_consolidated,
"sessions_moved": result.sessions_moved,
"data_files_moved": result.data_files_moved,
"modules_added": result.modules_added,
"audit_rows_written": result.audit_rows_written,
}
@router.get("/{project_id}") @router.get("/{project_id}")
async def get_project(project_id: str, db: Session = Depends(get_db)): async def get_project(project_id: str, db: Session = Depends(get_db)):
""" """
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
"""
project_merge.py consolidate a duplicate project into another.
Use case: the metadata-backfill parser (and operators) create projects with
slight name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
"Trumbull-Brayman-Mont Dam"). Operator picks a SOURCE project to merge
into a TARGET project; everything attached to source moves to target,
same-named locations consolidate, and source is soft-deleted.
Public API:
preview(db, source_id, target_id) MergePreview
execute(db, source_id, target_id, *, decided_by="operator") MergeResult
Both raise HTTPException with appropriate 4xx codes for validation failures.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from backend.models import (
Project,
ProjectModule,
MonitoringLocation,
UnitAssignment,
UnitHistory,
MonitoringSession,
DataFile,
)
log = logging.getLogger("backend.services.project_merge")
# ── Dataclasses ───────────────────────────────────────────────────────────────
@dataclass
class LocationMergePlan:
source_id: str
source_name: str
target_id: Optional[str] # None = will be inserted as-new under target project
target_name: Optional[str] # name in target after merge
action: str # "move" | "consolidate"
assignments_moving: int
sessions_moving: int
@dataclass
class MergePreview:
source_project_id: str
source_project_name: str
target_project_id: str
target_project_name: str
location_plans: list[LocationMergePlan] = field(default_factory=list)
total_assignments_moving: int = 0
total_sessions_moving: int = 0
total_data_files_moving: int = 0
modules_to_add: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@dataclass
class MergeResult:
source_project_id: str
target_project_id: str
assignments_moved: int
locations_moved: int
locations_consolidated: int
sessions_moved: int
data_files_moved: int
modules_added: list[str]
audit_rows_written: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _normalise_name(s: Optional[str]) -> str:
"""Case-insensitive, whitespace-collapsing name normalisation.
Lighter than metadata_backfill._normalise (no punctuation stripping)
for merging we want "Loc 1" and "Loc 1" to match but NOT "Loc 1"
and "Loc-1" (those might be intentionally different). If operators
DO want loose matching, they can rename one before merging.
"""
if not s:
return ""
import re
return re.sub(r"\s+", " ", s.strip()).casefold()
def _validate_pair(db: Session, source_id: str, target_id: str) -> tuple[Project, Project]:
if source_id == target_id:
raise HTTPException(status_code=400, detail="Cannot merge a project into itself.")
source = db.query(Project).filter_by(id=source_id).first()
target = db.query(Project).filter_by(id=target_id).first()
if source is None:
raise HTTPException(status_code=404, detail=f"Source project not found.")
if target is None:
raise HTTPException(status_code=404, detail=f"Target project not found.")
if source.status == "deleted":
raise HTTPException(status_code=400, detail=f"Source project '{source.name}' is already deleted.")
if target.status == "deleted":
raise HTTPException(status_code=400, detail=f"Target project '{target.name}' is deleted.")
return source, target
# ── Preview ───────────────────────────────────────────────────────────────────
def preview(db: Session, source_id: str, target_id: str) -> MergePreview:
"""Build a preview of what the merge will do. No writes."""
source, target = _validate_pair(db, source_id, target_id)
# Locations in source vs target.
source_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == source_id)
.all()
)
target_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == target_id)
.all()
)
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
location_plans: list[LocationMergePlan] = []
total_assignments_moving = 0
total_sessions_moving = 0
for sl in source_locs:
n = _normalise_name(sl.name)
tl = target_by_norm.get(n)
a_count = (
db.query(UnitAssignment)
.filter(UnitAssignment.location_id == sl.id)
.count()
)
s_count = (
db.query(MonitoringSession)
.filter(MonitoringSession.location_id == sl.id)
.count()
)
total_assignments_moving += a_count
total_sessions_moving += s_count
if tl is not None:
location_plans.append(LocationMergePlan(
source_id = sl.id,
source_name = sl.name,
target_id = tl.id,
target_name = tl.name,
action = "consolidate",
assignments_moving = a_count,
sessions_moving = s_count,
))
else:
location_plans.append(LocationMergePlan(
source_id = sl.id,
source_name = sl.name,
target_id = None,
target_name = sl.name,
action = "move",
assignments_moving = a_count,
sessions_moving = s_count,
))
# DataFiles attached to the source project (if the table exists with a
# project_id column). Optional — terra-view's DataFile model may not
# always FK to project, so handle gracefully.
df_count = 0
try:
df_count = (
db.query(DataFile)
.filter(DataFile.project_id == source_id)
.count()
)
except Exception:
df_count = 0
total_data_files_moving = df_count
# Modules: add anything in source missing from target.
src_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
.all()
}
tgt_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
.all()
}
modules_to_add = sorted(src_modules - tgt_modules)
warnings: list[str] = []
# Surface conditions the operator should think about.
consolidations = sum(1 for p in location_plans if p.action == "consolidate")
if consolidations:
warnings.append(
f"{consolidations} location(s) with matching names will be consolidated "
f"(source assignments will move to the target's existing location). "
f"If your same-named locations are actually different sites, rename one first."
)
if source.client_name and target.client_name and source.client_name.strip().casefold() != target.client_name.strip().casefold():
warnings.append(
f"Client names differ: source is \"{source.client_name}\", target is "
f"\"{target.client_name}\". Target's client name will be kept."
)
return MergePreview(
source_project_id = source.id,
source_project_name = source.name,
target_project_id = target.id,
target_project_name = target.name,
location_plans = location_plans,
total_assignments_moving = total_assignments_moving,
total_sessions_moving = total_sessions_moving,
total_data_files_moving = total_data_files_moving,
modules_to_add = modules_to_add,
warnings = warnings,
)
# ── Execute ───────────────────────────────────────────────────────────────────
def execute(
db: Session,
source_id: str,
target_id: str,
*,
decided_by: str = "operator",
) -> MergeResult:
"""Perform the merge in a single transaction.
Steps:
1. Re-validate the pair.
2. For each location in source:
- if a same-name location exists in target "consolidate" mode:
move source's assignments + sessions to target's location id,
delete source's location.
- else "move" mode: just re-point the location's project_id.
3. Move any remaining direct-to-project FK rows (DataFiles).
4. Ensure target has all of source's modules.
5. Soft-delete source project.
6. Write a UnitHistory row per assignment that was moved
(change_type='assignment_merged') so the deployment timeline
on each affected unit reflects the merge.
7. Commit.
"""
source, target = _validate_pair(db, source_id, target_id)
src_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
.all()
}
tgt_modules = {
m.module_type for m in db.query(ProjectModule)
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
.all()
}
modules_to_add = sorted(src_modules - tgt_modules)
# ── 1. Locations + their dependents ───────────────────────────────
source_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == source_id)
.all()
)
target_locs = (
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == target_id)
.all()
)
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
assignments_moved = 0
sessions_moved = 0
locations_moved = 0
locations_consolidated = 0
audit_rows_written = 0
for sl in source_locs:
n = _normalise_name(sl.name)
tl = target_by_norm.get(n)
# Pull this location's assignments + sessions (we'll re-point them).
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.location_id == sl.id)
.all()
)
sessions = (
db.query(MonitoringSession)
.filter(MonitoringSession.location_id == sl.id)
.all()
)
if tl is not None:
# Consolidate: move dependents to target's existing location;
# then delete the source location.
for a in assignments:
old_loc_id = a.location_id
a.location_id = tl.id
a.project_id = target.id
db.add(UnitHistory(
unit_id = a.unit_id,
change_type = "assignment_merged",
field_name = "unit_assignment.project_id",
old_value = f"{source.name} / {sl.name}",
new_value = f"{target.name} / {tl.name}",
changed_at = datetime.utcnow(),
source = "project_merge",
notes = (
f"Project merge: '{source.name}''{target.name}'. "
f"Location consolidated by name match. "
f"By: {decided_by}."
),
))
audit_rows_written += 1
assignments_moved += 1
for s in sessions:
s.location_id = tl.id
s.project_id = target.id
sessions_moved += 1
# Delete the now-empty source location.
db.delete(sl)
locations_consolidated += 1
else:
# Move: just re-point this location to the target project.
sl.project_id = target.id
for a in assignments:
old_proj_id = a.project_id
a.project_id = target.id
db.add(UnitHistory(
unit_id = a.unit_id,
change_type = "assignment_merged",
field_name = "unit_assignment.project_id",
old_value = f"{source.name} / {sl.name}",
new_value = f"{target.name} / {sl.name}",
changed_at = datetime.utcnow(),
source = "project_merge",
notes = (
f"Project merge: '{source.name}''{target.name}'. "
f"Location moved as-is. By: {decided_by}."
),
))
audit_rows_written += 1
assignments_moved += 1
for s in sessions:
s.project_id = target.id
sessions_moved += 1
locations_moved += 1
# ── 2. Direct-to-project rows (DataFiles, ScheduledActions) ──────
data_files_moved = 0
try:
data_files = (
db.query(DataFile)
.filter(DataFile.project_id == source_id)
.all()
)
for df in data_files:
df.project_id = target.id
data_files_moved += 1
except Exception as e:
log.warning("DataFile move skipped (model may differ): %s", e)
# ── 3. UnitAssignments that point directly at source.project_id with
# no location (shouldn't happen but be defensive) ──────────────
orphan_assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.project_id == source_id)
.all()
)
for a in orphan_assignments:
# Already moved if its location was moved. Catch any stragglers.
if a.project_id == source_id:
a.project_id = target.id
# ── 4. Modules ────────────────────────────────────────────────────
import uuid
for mod_type in modules_to_add:
db.add(ProjectModule(
id = str(uuid.uuid4()),
project_id = target.id,
module_type = mod_type,
enabled = True,
))
# Disable source's modules (defensive — source is being soft-deleted
# but its modules table rows could still be inspected).
for m in db.query(ProjectModule).filter(ProjectModule.project_id == source_id).all():
m.enabled = False
# ── 5. Soft-delete source ─────────────────────────────────────────
source.status = "deleted"
source.deleted_at = datetime.utcnow()
# Final audit row on the source project itself (operator-facing).
# We don't have a Project-level history table, so log on every
# affected unit as a marker. Already done per-assignment above.
db.commit()
return MergeResult(
source_project_id = source.id,
target_project_id = target.id,
assignments_moved = assignments_moved,
locations_moved = locations_moved,
locations_consolidated = locations_consolidated,
sessions_moved = sessions_moved,
data_files_moved = data_files_moved,
modules_added = modules_to_add,
audit_rows_written = audit_rows_written,
)
+330
View File
@@ -0,0 +1,330 @@
/* event-modal.js shared event-detail modal.
*
* Used by:
* - /sfm (admin Events tab)
* - /projects/{p}/nrl/{l} (project-location Events tab)
* - /unit/{id} (unit-detail SFM Events table)
*
* Pages must include partials/event_detail_modal.html in the body
* before this script is loaded.
*
* Public API:
* showEventDetail(eventId)
* Open the modal and fetch /api/sfm/db/events/{id}/sidecar to
* populate the rich BW report fields (peaks, ZC freq, sensor
* self-check, device info, etc.) into a tabbed/sectioned view.
*
* closeEventDetailModal()
* Close the modal.
*
* Notes:
* - Fetches sidecar live from SFM via terra-view's /api/sfm proxy.
* - Renders gracefully when the sidecar lacks a bw_report block
* (older events forwarded before the _ASCII.TXT pairing fix).
* - All functions are global on window so inline onclick handlers
* can reach them across all three host pages.
*/
(function () {
const MODAL_ID = 'event-detail-modal';
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _fmt(v, digits = 4, suffix = '') {
if (v == null || (typeof v === 'number' && Number.isNaN(v))) return '—';
if (typeof v === 'number') {
return v.toFixed(digits) + (suffix ? ` ${suffix}` : '');
}
return _esc(v) + (suffix ? ` ${suffix}` : '');
}
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 _kvCard(label, value, options = {}) {
// Single key-value tile. `value` is pre-rendered HTML (or text).
const colorCls = options.colorCls || '';
const valCls = `font-mono font-semibold ${colorCls}`;
return `<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">${_esc(label)}</div>
<div class="${valCls} mt-1">${value}</div>
${options.sub ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${options.sub}</div>` : ''}
</div>`;
}
function _sectionHeader(title, sub) {
return `<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-5 first:mt-0">
${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
</h4>`;
}
// ── Section renderers ────────────────────────────────────────────
function _renderEventHeader(s) {
const ev = s.event || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Serial</span> <span class="font-mono font-semibold text-seismo-orange ml-1">${_esc(ev.serial)}</span></div>
<div><span class="text-gray-500">Timestamp</span> <span class="font-medium ml-1">${ts}</span></div>
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(ev.record_type || '')}</span></div>
<div><span class="text-gray-500">Sample Rate</span> <span class="font-medium ml-1">${ev.sample_rate ?? ''} sps</span></div>
<div><span class="text-gray-500">Rec Time</span> <span class="font-medium ml-1">${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : ''}</span></div>
<div><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '')}</span></div>
</div>`;
}
function _renderProjectInfo(s) {
// The "user notes" metadata the operator typed into the BW device.
// These are the strings the future metadata-driven parser will use.
const p = s.project_info || {};
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '')}</span></div>
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '')}</span></div>
<div><span class="text-gray-500">Operator</span> <span class="font-medium ml-1">${_esc(p.operator || '')}</span></div>
<div><span class="text-gray-500">Sensor Location</span> <span class="font-medium ml-1">${_esc(p.sensor_location || '')}</span></div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
Values are as typed into the seismograph at session start not the terra-view project/location assignment.
</p>`;
}
function _renderPeakValues(s) {
// Prefer bw_report.peaks for richer per-channel data; fall back to peak_values.
const bwPeaks = (s.bw_report && s.bw_report.peaks) || null;
const pv = s.peak_values || {};
const tran = bwPeaks ? bwPeaks.tran?.ppv_ips : pv.transverse;
const vert = bwPeaks ? bwPeaks.vert?.ppv_ips : pv.vertical;
const lng = bwPeaks ? bwPeaks.long?.ppv_ips : pv.longitudinal;
const pvs = bwPeaks ? bwPeaks.vector_sum?.ips : pv.vector_sum;
const pvsAt = bwPeaks ? bwPeaks.vector_sum?.time_s : null;
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
${_kvCard('Transverse', `<span class="${_ppvClass(tran)}">${_fmt(tran, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Vertical', `<span class="${_ppvClass(vert)}">${_fmt(vert, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Longitudinal', `<span class="${_ppvClass(lng)}">${_fmt(lng, 4)}</span>`, { sub: 'in/s' })}
${_kvCard('Peak Vector Sum', `<span class="${_ppvClass(pvs)} text-base">${_fmt(pvs, 4)}</span>`, {
sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s',
})}
</div>`;
}
function _renderMic(s) {
const mic = (s.bw_report && s.bw_report.mic) || null;
const pv = s.peak_values || {};
if (!mic && pv.mic_psi == null) return '';
const dbl = mic?.pspl_dbl;
const psi = pv.mic_psi;
const zcHz = mic?.zc_freq_hz;
const tPk = mic?.time_of_peak_s;
const wt = mic?.weighting;
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
${_kvCard('Peak Mic psi', _fmt(psi, 4))}
${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
</div>`;
}
function _sensorRow(label, ch) {
if (!ch) {
return `<tr><td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
<td class="px-3 py-2 text-sm text-gray-400" colspan="3"></td></tr>`;
}
const result = ch.result || '—';
const resultCls = result === 'Passed'
? 'text-green-600 dark:text-green-400'
: (result === 'Failed' ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-500');
// Geo channels have freq + ratio; mic has freq + amplitude.
const rightCol = (ch.amplitude_mv != null)
? `<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.amplitude_mv, 1, 'mV')}</td>`
: `<td class="px-3 py-2 text-sm font-mono">${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}</td>`;
return `<tr>
<td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.freq_hz, 1, 'Hz')}</td>
${rightCol}
<td class="px-3 py-2 text-sm ${resultCls}">${_esc(result)}</td>
</tr>`;
}
function _renderSensorCheck(s) {
const sc = s.bw_report && s.bw_report.sensor_check;
if (!sc) return '';
return `<table class="w-full text-left rounded overflow-hidden border border-gray-200 dark:border-gray-700">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Channel</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Frequency</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Amplitude/Ratio</th>
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Result</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-slate-800">
${_sensorRow('Transverse', sc.tran)}
${_sensorRow('Vertical', sc.vert)}
${_sensorRow('Longitudinal', sc.long)}
${_sensorRow('Microphone', sc.mic)}
</tbody>
</table>`;
}
function _renderDeviceMetadata(s) {
const bw = s.bw_report || {};
const dev = bw.device || {};
const rec = bw.recording || {};
return `<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Firmware</span> <span class="font-mono text-xs ml-1">${_esc(bw.version || '')}</span></div>
<div><span class="text-gray-500">Battery</span> <span class="font-medium ml-1">${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : ''}</span></div>
<div><span class="text-gray-500">Calibrated</span> <span class="font-medium ml-1">${_esc(dev.calibration_date || '')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}</span></div>
<div><span class="text-gray-500">Geo Range</span> <span class="font-medium ml-1">${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : ''}</span></div>
<div><span class="text-gray-500">Stop Mode</span> <span class="font-medium ml-1">${_esc(rec.stop_mode || '')}</span></div>
<div><span class="text-gray-500">Units</span> <span class="font-medium ml-1">${_esc(rec.units || '')}</span></div>
</div>`;
}
function _renderFileInfo(s, eventId) {
const bw = s.blastware || {};
const src = s.source || {};
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
const downloadButtons = `
<div class="flex flex-wrap gap-2 mb-4">
${canDownloadBinary ? `
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
download="${_esc(bw.filename)}"
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download Blastware file
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
</a>
` : `
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Blastware file unavailable
</span>
`}
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar"
download="${_esc((bw.filename || 'event') + '.sfm.json')}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Download sidecar JSON
</a>
</div>
`;
return `${downloadButtons}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '')}</span></div>
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '')}</span></div>
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '')}</span></div>
</div>`;
}
// ── Public API ───────────────────────────────────────────────────
window.showEventDetail = async function (eventId) {
const modal = document.getElementById(MODAL_ID);
if (!modal) {
console.warn('event-modal: include event_detail_modal.html partial on this page.');
return;
}
modal.classList.remove('hidden');
document.getElementById(MODAL_ID + '-title').textContent = 'Event Detail';
document.getElementById(MODAL_ID + '-content').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 event detail
</div>`;
let s;
try {
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`);
if (!r.ok) {
throw new Error('HTTP ' + r.status + ' fetching sidecar');
}
s = await r.json();
} catch (e) {
document.getElementById(MODAL_ID + '-content').innerHTML = `
<div class="text-center py-8 text-red-500 text-sm">
Failed to load event detail: ${_esc(e.message)}
</div>`;
return;
}
const ev = s.event || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '';
document.getElementById(MODAL_ID + '-title').textContent =
`Event — ${ev.serial || '?'} @ ${ts}`;
const hasReport = !!s.bw_report;
const reportNote = hasReport
? ''
: `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-300 mb-4">
<strong>No BW ASCII report paired with this event.</strong>
Older events forwarded before the watcher's <code class="font-mono text-xs">_ASCII.TXT</code> pairing fix landed lack this data.
PPV is still available from the binary event file.
</div>`;
document.getElementById(MODAL_ID + '-content').innerHTML = `
${reportNote}
${_sectionHeader('Event')}
${_renderEventHeader(s)}
${_sectionHeader('Project Info', '(operator-typed at session start)')}
${_renderProjectInfo(s)}
${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)}
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
${_sectionHeader('Microphone')}
${_renderMic(s)}
` : ''}
${hasReport ? `
${_sectionHeader('Sensor Self-Check')}
${_renderSensorCheck(s)}
${_sectionHeader('Device & Recording Metadata')}
${_renderDeviceMetadata(s)}
` : ''}
${_sectionHeader('Source File')}
${_renderFileInfo(s, eventId)}
`;
};
window.closeEventDetailModal = function () {
const modal = document.getElementById(MODAL_ID);
if (modal) modal.classList.add('hidden');
};
// Close on Escape.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.closeEventDetailModal();
});
})();
+1
View File
@@ -8,3 +8,4 @@ aiofiles==23.2.1
Pillow==10.1.0 Pillow==10.1.0
httpx==0.25.2 httpx==0.25.2
openpyxl==3.1.2 openpyxl==3.1.2
rapidfuzz==3.10.1
+637
View File
@@ -0,0 +1,637 @@
{% extends "base.html" %}
{% block title %}Metadata Backfill - Seismo Fleet Manager{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center space-x-2 text-sm">
<a href="/settings" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Settings
</a>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-gray-900 dark:text-white font-medium">Metadata Backfill</span>
</nav>
</div>
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Backfill from event metadata</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Auto-create projects, locations, and unit assignments from operator-typed metadata on Blastware events.
</p>
</div>
<!-- Summary card (populated after scan) -->
<div id="summary-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<div id="summary-initial">
<div class="text-center py-8">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Scan SFM events</h2>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-6 max-w-xl mx-auto">
Reads all events from SFM, clusters them by serial &amp; time, matches the
operator-typed metadata against your existing projects, and proposes
<strong>Project</strong> / <strong>Location</strong> / <strong>UnitAssignment</strong>
chains to create.
</p>
<button onclick="runScan(false)"
id="initial-scan-btn"
class="px-6 py-3 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
↻ Run scan
</button>
</div>
</div>
<div id="summary-results" class="hidden">
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Scan summary</h2>
<p id="summary-scanned-at" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></p>
</div>
<button onclick="runScan(true)"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg">
↻ Re-scan
</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">Events scanned</span>
<span id="kpi-scanned" 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">Already attributed</span>
<span id="kpi-already" 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">inside existing assignments</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">Pending review</span>
<span id="kpi-pending" 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">clusters to attribute</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">Conflicts</span>
<span id="kpi-conflicts" 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">need manual reconciliation</span>
</div>
</div>
<!-- One-click bulk apply -->
<div id="bulk-apply-card" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-4 hidden">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-seismo-orange shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<div class="flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">
Bulk-apply <span id="bulk-applicable-count">0</span> high-confidence cluster(s)
</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Apply every cluster scored <strong>high confidence</strong> with no blocking conflicts.
Will create <span id="bulk-stats" class="font-medium"></span>.
Medium and low confidence clusters remain in the list below for individual review.
</p>
<button onclick="applyBulkHighConfidence()"
class="px-5 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
Apply all high-confidence
</button>
</div>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
Each cluster below shows the operator-typed metadata, what would be created or matched, and the proposed
assignment date window. Click <em>Apply</em> to attribute that cluster, <em>Skip</em> to ignore it (won't reappear),
or <em>Edit</em> to rename before applying.
</p>
</div>
</div>
<!-- Cluster list -->
<div id="cluster-list" class="space-y-3"></div>
<!-- Apply progress toast -->
<div id="apply-toast" class="hidden fixed bottom-6 right-6 bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 p-4 z-50 max-w-md">
<div class="flex items-center gap-3">
<div id="toast-icon" class="shrink-0">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>
</div>
<div class="flex-1">
<p id="toast-message" class="text-sm font-medium text-gray-900 dark:text-white">Applying…</p>
<p id="toast-sub" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
</div>
</div>
</div>
<!-- Shared event-detail modal (Preview event button uses it) -->
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
<script>
let _scanData = null;
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDate(iso) {
if (!iso) return '—';
return iso.slice(0, 10);
}
function _fmtDateTime(iso) {
if (!iso) return '—';
return iso.slice(0, 19).replace('T', ' ');
}
function _confidenceBadge(c) {
const map = {
high: { cls: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: '🟢', label: 'high' },
medium: { cls: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', icon: '🟡', label: 'medium' },
low: { cls: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: '🔴', label: 'low' },
};
const e = map[c] || map.low;
return `<span class="px-2 py-0.5 rounded text-xs font-medium ${e.cls}">${e.icon} ${e.label}</span>`;
}
function _matchPill(match, score, suggestedName, existingName) {
if (match === 'exact') {
return `<span class="font-medium text-green-700 dark:text-green-400">✓ Matches existing: <em>${_esc(existingName || suggestedName)}</em></span>`;
}
if (match === 'fuzzy') {
return `<span class="font-medium text-amber-700 dark:text-amber-400">≈ Fuzzy match (${(score*100).toFixed(0)}%): <em>${_esc(existingName)}</em></span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(your value: "${_esc(suggestedName)}")</span>`;
}
if (match === 'ambiguous') {
return `<span class="font-medium text-yellow-700 dark:text-yellow-400">? Ambiguous — multiple matches</span>`;
}
return `<span class="font-medium text-seismo-orange">+ Create new: <em>${_esc(suggestedName)}</em></span>`;
}
// Compact "hint" line under each typeahead input, showing what the parser
// thinks the current value will do (match existing vs create new).
function _matchHint(match, score, existingName, suggestedName) {
if (match === 'exact') {
return `<span class="text-green-700 dark:text-green-400">✓ matches existing</span>`;
}
if (match === 'fuzzy') {
return `<span class="text-amber-700 dark:text-amber-400">≈ fuzzy match to "${_esc(existingName)}" (${(score*100).toFixed(0)}%)</span>`;
}
if (match === 'ambiguous') {
return `<span class="text-yellow-700 dark:text-yellow-400">? ambiguous — pick from dropdown</span>`;
}
return `<span class="text-seismo-orange">+ will create new</span>`;
}
// ── Typeahead ────────────────────────────────────────────────────────────
// Per-cluster project + location inputs with debounced typeahead search.
// Selecting a result writes the existing entity's id into the hidden
// project_id / location_id input; clearing-and-typing falls back to
// "create new" semantics.
let _typeaheadDebounce = null;
function onTypeaheadInput(e, fieldKind) {
// fieldKind ∈ {'project', 'location'}
const inp = e.target;
const cid = inp.dataset.clusterId;
// Clear the "id" hidden input — operator is typing freely now.
const hidden = document.querySelector(`input[type="hidden"][data-cluster-id="${cid}"][data-field="${fieldKind}_id"]`);
if (hidden) hidden.value = '';
// Debounce the search.
if (_typeaheadDebounce) clearTimeout(_typeaheadDebounce);
_typeaheadDebounce = setTimeout(() => _fetchTypeahead(inp, fieldKind), 150);
}
function onTypeaheadFocus(e, fieldKind) {
_fetchTypeahead(e.target, fieldKind);
}
function onTypeaheadBlur(e) {
// Delayed hide so a click on the dropdown can register first.
const dropdown = e.target.parentElement.querySelector('.typeahead-dropdown');
if (dropdown) {
setTimeout(() => dropdown.classList.add('hidden'), 150);
}
}
async function _fetchTypeahead(input, fieldKind) {
const dropdown = input.parentElement.querySelector('.typeahead-dropdown');
if (!dropdown) return;
const q = input.value.trim();
const cid = input.dataset.clusterId;
let url;
if (fieldKind === 'project') {
url = `/api/admin/metadata_backfill/projects_search?q=${encodeURIComponent(q)}`;
} else {
// For locations, scope to the currently-chosen project (if any).
const projectIdInput = document.querySelector(`input[type="hidden"][data-cluster-id="${cid}"][data-field="project_id"]`);
const projectId = projectIdInput ? projectIdInput.value : '';
if (!projectId) {
// Operator hasn't picked an existing project — there are no
// existing locations to match against (location is implicitly
// "create new" inside a new project).
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">
${q ? `+ Will create new: <strong>"${_esc(q)}"</strong>` : 'Pick a project first, or type a new location name.'}
</div>`;
dropdown.classList.remove('hidden');
return;
}
url = `/api/admin/metadata_backfill/locations_search?project_id=${encodeURIComponent(projectId)}&q=${encodeURIComponent(q)}`;
}
let data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
data = await r.json();
} catch (err) {
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-red-500">Search failed: ${_esc(err.message)}</div>`;
dropdown.classList.remove('hidden');
return;
}
const items = [];
for (const m of (data.matches || [])) {
items.push({ kind: 'match', payload: m });
}
if (data.create_new && data.create_new.label) {
items.push({ kind: 'create_new', label: data.create_new.label, name: q });
}
if (items.length === 0) {
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">No matches. Type to create.</div>`;
dropdown.classList.remove('hidden');
return;
}
dropdown.innerHTML = items.map((it, idx) => {
if (it.kind === 'match') {
const m = it.payload;
const scoreBadge = m.score >= 0.99
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
: `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${(m.score*100).toFixed(0)}%</span>`;
const meta = [];
if (fieldKind === 'project') {
if (m.project_number) meta.push(_esc(m.project_number));
if (m.client_name) meta.push(_esc(m.client_name));
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
} else {
if (m.address) meta.push(_esc(m.address));
}
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
return `<button type="button"
onmousedown="event.preventDefault()"
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})"
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
${metaLine}
</button>`;
}
return `<button type="button"
onmousedown="event.preventDefault()"
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})"
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
+ ${_esc(it.label)}
</button>`;
}).join('');
dropdown.classList.remove('hidden');
}
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
// entityId is empty string for "create new", or a UUID for matched existing.
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
let textInput = null;
let idInput = null;
inputs.forEach(i => {
if (i.dataset.field === fieldKind) textInput = i;
if (i.dataset.field === fieldKind + '_id') idInput = i;
});
if (textInput) textInput.value = name;
if (idInput) idInput.value = entityId;
// Hide this dropdown.
const dropdown = textInput.parentElement.querySelector('.typeahead-dropdown');
if (dropdown) dropdown.classList.add('hidden');
// If operator just picked a NEW project, clear the location id (forces
// operator to pick a location under the new project rather than leaving
// a stale id from another project).
if (fieldKind === 'project') {
const locId = document.querySelector(`input[type="hidden"][data-cluster-id="${clusterId}"][data-field="location_id"]`);
if (locId) locId.value = '';
}
}
async function runScan(force) {
const initial = document.getElementById('summary-initial');
const results = document.getElementById('summary-results');
const list = document.getElementById('cluster-list');
initial.classList.add('hidden');
results.classList.remove('hidden');
list.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>Scanning events…</div>';
document.getElementById('kpi-scanned').textContent = '…';
document.getElementById('kpi-already').textContent = '…';
document.getElementById('kpi-pending').textContent = '…';
document.getElementById('kpi-conflicts').textContent = '…';
document.getElementById('bulk-apply-card').classList.add('hidden');
try {
const r = await fetch('/api/admin/metadata_backfill/scan' + (force ? '?force=true' : ''));
if (!r.ok) throw new Error('HTTP ' + r.status);
_scanData = await r.json();
} catch (e) {
list.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center text-red-500">Scan failed: ${_esc(e.message)}</div>`;
return;
}
document.getElementById('kpi-scanned').textContent = _scanData.scanned_event_count.toLocaleString();
document.getElementById('kpi-already').textContent = _scanData.already_attributed.toLocaleString();
document.getElementById('kpi-pending').textContent = _scanData.pending_count.toLocaleString();
document.getElementById('kpi-conflicts').textContent = _scanData.blocking_conflict_count.toLocaleString();
document.getElementById('summary-scanned-at').textContent =
'Scanned ' + new Date(_scanData.scanned_at * 1000).toLocaleString();
// Configure bulk-apply card.
const highApplicable = _scanData.by_confidence.high.filter(s => !s.blocking_conflict);
const newProjects = new Set(), newLocations = new Set();
for (const s of highApplicable) {
if (s.project_match === 'create_new') newProjects.add(s.project_suggested_name.toLowerCase());
if (s.location_match === 'create_new') newLocations.add(s.location_suggested_name.toLowerCase());
}
if (highApplicable.length > 0) {
document.getElementById('bulk-apply-card').classList.remove('hidden');
document.getElementById('bulk-applicable-count').textContent = highApplicable.length;
const parts = [];
if (newProjects.size > 0) parts.push(`${newProjects.size} project${newProjects.size === 1 ? '' : 's'}`);
if (newLocations.size > 0) parts.push(`${newLocations.size} location${newLocations.size === 1 ? '' : 's'}`);
parts.push(`${highApplicable.length} assignment${highApplicable.length === 1 ? '' : 's'}`);
document.getElementById('bulk-stats').textContent = parts.join(' · ');
}
renderClusterList();
}
function renderClusterList() {
const list = document.getElementById('cluster-list');
const all = [
..._scanData.by_confidence.high,
..._scanData.by_confidence.medium,
..._scanData.by_confidence.low,
];
if (all.length === 0) {
list.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">✅ All caught up</h3>
<p class="text-gray-500 dark:text-gray-400">Every event in SFM is either attributed to an existing assignment or has been skipped.</p>
</div>`;
return;
}
list.innerHTML = all.map(_renderCluster).join('');
}
function _renderCluster(s) {
const spanDays = (new Date(s.last_event_ts) - new Date(s.first_event_ts)) / 86400000;
const consistencyNote = s.metadata_consistency < 1.0
? `<span class="ml-2 text-xs text-amber-600 dark:text-amber-400" title="Some events in this cluster have slightly different metadata — possibly a typo or mid-stream change.">⚠ ${(s.metadata_consistency*100).toFixed(0)}% consistent</span>`
: '';
const blockingBanner = s.blocking_conflict
? `<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 mt-3 text-sm text-red-800 dark:text-red-300">
<strong>⚠ Blocking conflict.</strong>
${s.conflicts.map(c => `Unit ${_esc(s.serial)} is already assigned to <em>${_esc(c.other_project_name)} / ${_esc(c.other_location_name)}</em> during this window.`).join(' ')}
Resolve manually before this cluster can be applied.
</div>`
: '';
const orphanInputs = s.is_blank_meta
? `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 mt-3">
<p class="text-sm text-amber-800 dark:text-amber-300 mb-2"><strong>⚠ Blank metadata.</strong> Operator didn't type project / location for these events. Fill in manually:</p>
<div class="grid grid-cols-2 gap-2">
<input type="text" placeholder="Project name" data-cluster-id="${_esc(s.cluster_id)}" data-field="project_name"
class="px-2 py-1 text-sm border border-amber-300 dark:border-amber-700 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<input type="text" placeholder="Location name" data-cluster-id="${_esc(s.cluster_id)}" data-field="location_name"
class="px-2 py-1 text-sm border border-amber-300 dark:border-amber-700 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
</div>`
: '';
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4" data-cluster-id="${_esc(s.cluster_id)}">
<div class="flex items-start justify-between gap-3 mb-3 flex-wrap">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
${_confidenceBadge(s.confidence)}
<a href="/unit/${_esc(s.serial)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${_esc(s.serial)}</a>
<span class="text-sm text-gray-600 dark:text-gray-400">${_fmtDate(s.first_event_ts)} → ${_fmtDate(s.last_event_ts)}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">(${s.event_count} event${s.event_count === 1 ? '' : 's'}, ${spanDays.toFixed(0)}d span)</span>
${consistencyNote}
</div>
<div class="text-sm text-gray-700 dark:text-gray-300 mt-2 space-y-2">
<!-- Project typeahead -->
<div class="flex items-start gap-2">
<span class="text-gray-500 dark:text-gray-400 w-24 shrink-0 pt-1.5">Project:</span>
<div class="flex-1 relative">
<input type="text"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
value="${_esc(s.project_existing_name || s.project_suggested_name)}"
data-cluster-id="${_esc(s.cluster_id)}"
data-field="project"
data-initial-project-id="${_esc(s.project_existing_id || '')}"
placeholder="Type to search or create…"
oninput="onTypeaheadInput(event, 'project')"
onfocus="onTypeaheadFocus(event, 'project')"
onblur="onTypeaheadBlur(event)"
autocomplete="off">
<input type="hidden" data-cluster-id="${_esc(s.cluster_id)}" data-field="project_id" value="${_esc(s.project_existing_id || '')}">
<div class="typeahead-dropdown hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
<div class="text-xs mt-0.5">${_matchHint(s.project_match, s.project_match_score, s.project_existing_name, s.project_suggested_name)}</div>
${(s.project_root && s.project_raw && s.project_root !== s.project_raw)
? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">↳ stripped trailing "Loc N" suffix; operator typed: <em>"${_esc(s.project_raw)}"</em></div>`
: ''}
</div>
</div>
<!-- Location typeahead -->
<div class="flex items-start gap-2">
<span class="text-gray-500 dark:text-gray-400 w-24 shrink-0 pt-1.5">Location:</span>
<div class="flex-1 relative">
<input type="text"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
value="${_esc(s.location_existing_name || s.location_suggested_name)}"
data-cluster-id="${_esc(s.cluster_id)}"
data-field="location"
data-initial-location-id="${_esc(s.location_existing_id || '')}"
placeholder="Type to search or create…"
oninput="onTypeaheadInput(event, 'location')"
onfocus="onTypeaheadFocus(event, 'location')"
onblur="onTypeaheadBlur(event)"
autocomplete="off">
<input type="hidden" data-cluster-id="${_esc(s.cluster_id)}" data-field="location_id" value="${_esc(s.location_existing_id || '')}">
<div class="typeahead-dropdown hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
<div class="text-xs mt-0.5">${_matchHint(s.location_match, s.location_match_score, s.location_existing_name, s.location_suggested_name)}</div>
</div>
</div>
<div><span class="text-gray-500 dark:text-gray-400 w-24 inline-block">Assignment:</span> ${_fmtDateTime(s.proposed_assigned_at)} → ${s.proposed_assigned_until ? _fmtDateTime(s.proposed_assigned_until) : '<span class="text-green-700 dark:text-green-400 font-medium">present (active)</span>'}</div>
${s.client_raw ? `<div><span class="text-gray-500 dark:text-gray-400 w-24 inline-block">Client:</span> <em>${_esc(s.client_raw)}</em></div>` : ''}
</div>
${blockingBanner}
${orphanInputs}
</div>
<div class="flex flex-col gap-2 shrink-0">
<button onclick="showEventDetail('${_esc(s.sample_event_id)}')"
class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded">
Preview event
</button>
${s.blocking_conflict
? `<button disabled class="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-400 rounded cursor-not-allowed">Apply</button>`
: `<button onclick="applyOne('${_esc(s.cluster_id)}')"
class="px-3 py-1.5 text-xs bg-seismo-orange hover:bg-seismo-navy text-white rounded font-medium">
Apply
</button>`}
<button onclick="skipOne('${_esc(s.cluster_id)}')"
class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded">
Skip
</button>
</div>
</div>
</div>`;
}
function _gatherOverrides(clusterIds) {
// Per-cluster overrides sent to /apply. The backend understands four
// keys per cluster: project_id, project_name, location_id, location_name.
// We emit project_id+location_id when the operator picked from the
// typeahead dropdown; we emit project_name+location_name when they
// typed a free-form value (no id selected) that differs from the
// parser's original suggestion.
const overrides = {};
for (const cid of clusterIds) {
const inputs = document.querySelectorAll(`input[data-cluster-id="${cid}"]`);
if (inputs.length === 0) continue;
const o = {};
let projectText = null, projectId = null;
let locationText = null, locationId = null;
// Old-style flat fields (kept for blank-meta cluster inputs which
// use data-field="project_name" / "location_name").
let projectNameRaw = null, locationNameRaw = null;
inputs.forEach(i => {
const v = (i.value || '').trim();
const f = i.dataset.field;
if (f === 'project') projectText = v;
else if (f === 'project_id') projectId = v;
else if (f === 'location') locationText = v;
else if (f === 'location_id') locationId = v;
else if (f === 'project_name') projectNameRaw = v;
else if (f === 'location_name') locationNameRaw = v;
});
if (projectId) {
o.project_id = projectId;
} else if (projectText) {
o.project_name = projectText;
} else if (projectNameRaw) {
o.project_name = projectNameRaw;
}
if (locationId) {
o.location_id = locationId;
} else if (locationText) {
o.location_name = locationText;
} else if (locationNameRaw) {
o.location_name = locationNameRaw;
}
if (Object.keys(o).length > 0) overrides[cid] = o;
}
return overrides;
}
function _showToast(message, sub, kind) {
const toast = document.getElementById('apply-toast');
const icon = document.getElementById('toast-icon');
document.getElementById('toast-message').textContent = message;
document.getElementById('toast-sub').textContent = sub || '';
if (kind === 'success') {
icon.innerHTML = '<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
} else if (kind === 'error') {
icon.innerHTML = '<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>';
} else {
icon.innerHTML = '<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>';
}
toast.classList.remove('hidden');
}
function _hideToast(after) {
setTimeout(() => document.getElementById('apply-toast').classList.add('hidden'), after || 3000);
}
async function _apply(clusterIds) {
if (clusterIds.length === 0) return;
_showToast(`Applying ${clusterIds.length} cluster${clusterIds.length === 1 ? '' : 's'}…`);
try {
const r = await fetch('/api/admin/metadata_backfill/apply', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
cluster_ids: clusterIds,
overrides: _gatherOverrides(clusterIds),
}),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const sub = `${d.applied} applied · ${d.project_ids_created.length} new project(s) · ${d.location_ids_created.length} new location(s)` + (d.failed.length ? ` · ${d.failed.length} failed` : '');
_showToast(`${d.applied} cluster${d.applied === 1 ? '' : 's'} applied`, sub, d.failed.length ? 'error' : 'success');
_hideToast(4000);
await runScan(true); // refresh
} catch (e) {
_showToast('Apply failed', e.message, 'error');
_hideToast(5000);
}
}
async function applyOne(clusterId) { return _apply([clusterId]); }
async function applyBulkHighConfidence() {
const high = _scanData.by_confidence.high.filter(s => !s.blocking_conflict);
const ids = high.map(s => s.cluster_id);
if (ids.length === 0) return;
if (!confirm(`Apply ${ids.length} high-confidence cluster${ids.length === 1 ? '' : 's'}? This will create projects, locations, and assignments without further prompting.`)) return;
return _apply(ids);
}
async function skipOne(clusterId) {
if (!confirm('Skip this cluster? It will not reappear in future scans.')) return;
_showToast('Skipping…');
try {
const r = await fetch('/api/admin/metadata_backfill/skip', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ cluster_ids: [clusterId] }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
_showToast('Skipped', '', 'success');
_hideToast(2000);
await runScan(true);
} catch (e) {
_showToast('Skip failed', e.message, 'error');
_hideToast(4000);
}
}
</script>
{% endblock %}
@@ -0,0 +1,25 @@
{# Shared event detail modal.
Include this partial on any page that wants to call showEventDetail(eventId)
from event-modal.js. The partial provides only the modal shell — the
actual content is rendered by JS into #event-detail-modal-content.
Usage:
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
#}
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
<button onclick="closeEventDetailModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
<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>
<div id="event-detail-modal-content"></div>
</div>
</div>
@@ -75,10 +75,266 @@
Generate Combined Report Generate Combined Report
</a> </a>
{% endif %} {% endif %}
<button onclick="openMergeModal()"
title="Merge this project into another (consolidates duplicates)"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
Merge into…
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Merge Modal -->
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Merge "{{ project.name }}" into another project</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Source project gets soft-deleted. All its locations, assignments, sessions, and files move to the target.
</p>
</div>
<button onclick="closeMergeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 ml-3">
<svg class="w-5 h-5" 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"/></svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Target project
</label>
<div class="relative">
<input type="text"
id="merge-target-input"
placeholder="Type to search for the project to merge INTO…"
autocomplete="off"
oninput="onMergeTargetInput()"
onfocus="onMergeTargetInput()"
onblur="setTimeout(() => document.getElementById('merge-target-dropdown').classList.add('hidden'), 150)"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<input type="hidden" id="merge-target-id" value="">
<div id="merge-target-dropdown"
class="hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
</div>
<!-- Preview pane -->
<div id="merge-preview" class="mt-4 hidden">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">What will move</h4>
<div id="merge-preview-body" class="space-y-3"></div>
</div>
<div id="merge-error" class="hidden mt-3 text-sm text-red-600"></div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button onclick="closeMergeModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
Cancel
</button>
<button id="merge-confirm-btn"
onclick="confirmMerge()"
disabled
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed">
Merge (permanent)
</button>
</div>
</div>
</div>
<script>
const _SOURCE_PROJECT_ID = "{{ project.id }}";
const _SOURCE_PROJECT_NAME = {{ project.name|tojson }};
function _mergeEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function openMergeModal() {
document.getElementById('merge-target-input').value = '';
document.getElementById('merge-target-id').value = '';
document.getElementById('merge-preview').classList.add('hidden');
document.getElementById('merge-error').classList.add('hidden');
document.getElementById('merge-confirm-btn').disabled = true;
document.getElementById('merge-modal').classList.remove('hidden');
setTimeout(() => document.getElementById('merge-target-input').focus(), 50);
}
function closeMergeModal() {
document.getElementById('merge-modal').classList.add('hidden');
}
let _mergeTargetDebounce = null;
async function onMergeTargetInput() {
if (_mergeTargetDebounce) clearTimeout(_mergeTargetDebounce);
_mergeTargetDebounce = setTimeout(_mergeFetchTargets, 150);
}
async function _mergeFetchTargets() {
const q = document.getElementById('merge-target-input').value.trim();
const dropdown = document.getElementById('merge-target-dropdown');
let data;
try {
// Reuse the metadata-backfill projects_search endpoint — works for
// any caller, returns existing-only (no create-new option needed here).
const r = await fetch(`/api/admin/metadata_backfill/projects_search?q=${encodeURIComponent(q)}&limit=12`);
if (!r.ok) throw new Error('HTTP ' + r.status);
data = await r.json();
} catch (e) {
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-red-500">Search failed: ${_mergeEsc(e.message)}</div>`;
dropdown.classList.remove('hidden');
return;
}
// Filter out self.
const candidates = (data.matches || []).filter(m => m.id !== _SOURCE_PROJECT_ID);
if (candidates.length === 0) {
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">No matches.</div>`;
dropdown.classList.remove('hidden');
return;
}
dropdown.innerHTML = candidates.map(m => {
const scoreBadge = m.score >= 0.99
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
: `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${(m.score*100).toFixed(0)}%</span>`;
const meta = [];
if (m.project_number) meta.push(_mergeEsc(m.project_number));
if (m.client_name) meta.push(_mergeEsc(m.client_name));
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
return `<button type="button"
onmousedown="event.preventDefault()"
onclick="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
${metaLine}
</button>`;
}).join('');
dropdown.classList.remove('hidden');
}
async function onMergePickTarget(targetId, targetName) {
document.getElementById('merge-target-input').value = targetName;
document.getElementById('merge-target-id').value = targetId;
document.getElementById('merge-target-dropdown').classList.add('hidden');
await _loadMergePreview(targetId);
}
async function _loadMergePreview(targetId) {
const previewEl = document.getElementById('merge-preview');
const bodyEl = document.getElementById('merge-preview-body');
const errorEl = document.getElementById('merge-error');
const confirmBtn = document.getElementById('merge-confirm-btn');
previewEl.classList.add('hidden');
errorEl.classList.add('hidden');
confirmBtn.disabled = true;
bodyEl.innerHTML = '<div class="text-center py-3 text-sm text-gray-500"><div class="animate-spin rounded-full h-5 w-5 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading preview…</div>';
previewEl.classList.remove('hidden');
let d;
try {
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_preview?target_id=${encodeURIComponent(targetId)}`);
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || ('HTTP ' + r.status));
}
d = await r.json();
} catch (e) {
errorEl.textContent = e.message;
errorEl.classList.remove('hidden');
previewEl.classList.add('hidden');
return;
}
let html = `<div class="text-sm text-gray-700 dark:text-gray-300">
Merging <strong>"${_mergeEsc(d.source_project_name)}"</strong> into <strong>"${_mergeEsc(d.target_project_name)}"</strong>:
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Assignments</div><div class="text-lg font-bold">${d.total_assignments_moving}</div></div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Sessions</div><div class="text-lg font-bold">${d.total_sessions_moving}</div></div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Data files</div><div class="text-lg font-bold">${d.total_data_files_moving}</div></div>
</div>`;
if (d.location_plans.length > 0) {
html += `<div class="mt-3">
<h5 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Locations</h5>
<div class="space-y-1 text-sm">`;
for (const p of d.location_plans) {
if (p.action === 'consolidate') {
html += `<div class="text-gray-700 dark:text-gray-300">
🔀 <strong>${_mergeEsc(p.source_name)}</strong> → consolidates into existing target <strong>"${_mergeEsc(p.target_name)}"</strong>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions move)</span>
</div>`;
} else {
html += `<div class="text-gray-700 dark:text-gray-300">
<strong>${_mergeEsc(p.source_name)}</strong> moves to target as-is
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions)</span>
</div>`;
}
}
html += '</div></div>';
}
if (d.modules_to_add.length > 0) {
html += `<div class="mt-3 text-sm text-gray-700 dark:text-gray-300">
Modules to add to target: ${d.modules_to_add.map(m => `<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">${_mergeEsc(m)}</code>`).join(' ')}
</div>`;
}
if (d.warnings.length > 0) {
html += '<div class="mt-3 space-y-2">';
for (const w of d.warnings) {
html += `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded p-2 text-xs text-amber-800 dark:text-amber-300">⚠ ${_mergeEsc(w)}</div>`;
}
html += '</div>';
}
html += `<div class="mt-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2 text-xs text-red-800 dark:text-red-300">
<strong>⚠ This action is destructive.</strong> Source project will be soft-deleted (status='deleted').
Audit rows will be written to unit_history for every moved assignment.
</div>`;
bodyEl.innerHTML = html;
confirmBtn.disabled = false;
}
async function confirmMerge() {
const targetId = document.getElementById('merge-target-id').value;
if (!targetId) return;
const confirmBtn = document.getElementById('merge-confirm-btn');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Merging…';
try {
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_into?target_id=${encodeURIComponent(targetId)}`, {
method: 'POST',
});
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();
// Redirect to the target project — source no longer exists in active state.
window.location.href = `/projects/${d.target_project_id}`;
} catch (e) {
const errorEl = document.getElementById('merge-error');
errorEl.textContent = 'Merge failed: ' + e.message;
errorEl.classList.remove('hidden');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Merge (permanent)';
}
}
</script>
<!-- Add Module Modal --> <!-- Add Module Modal -->
<div id="add-module-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> <div id="add-module-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
+14
View File
@@ -560,6 +560,20 @@
Open Open
</a> </a>
</div> </div>
<!-- Metadata Backfill (Phase 5a) -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
<div>
<div class="font-medium text-gray-900 dark:text-white">Backfill from event metadata</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Auto-create projects, locations, and unit assignments from the operator-typed metadata baked into SFM events. Skip the manual entry.
</div>
</div>
<a href="/settings/developer/metadata-backfill"
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
Open
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
+6 -66
View File
@@ -115,21 +115,9 @@
</div><!-- end tab container --> </div><!-- end tab container -->
<!-- Event detail modal --> {# Shared event-detail modal — rendered by /static/event-modal.js #}
<div id="event-modal" class="fixed inset-0 z-50 hidden"> {% include 'partials/event_detail_modal.html' %}
<div class="absolute inset-0 bg-black/60" onclick="closeEventModal()"></div> <script src="/static/event-modal.js"></script>
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-2xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[85vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="modal-title">Event Detail</h3>
<button onclick="closeEventModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<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>
<div id="modal-content"></div>
</div>
</div>
<style> <style>
.sfm-tab { .sfm-tab {
@@ -275,7 +263,7 @@ function renderEventsTable(events, total, container) {
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 dark:text-yellow-200">FT</span>` : ''; 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 dark:text-yellow-200">FT</span>` : '';
const proj = ev.project ? `<span class="truncate max-w-[120px] inline-block" title="${esc(ev.project)}">${esc(ev.project)}</span>` : '<span class="text-gray-400"></span>'; const proj = ev.project ? `<span class="truncate max-w-[120px] inline-block" title="${esc(ev.project)}">${esc(ev.project)}</span>` : '<span class="text-gray-400"></span>';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail(${JSON.stringify(JSON.stringify(ev))})"> return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail('${esc(ev.id)}')">
<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 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">${esc(ev.serial)}</td> <td class="px-4 py-2.5 text-sm font-mono font-medium text-seismo-orange">${esc(ev.serial)}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[140px]">${proj}</td> <td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[140px]">${proj}</td>
@@ -354,56 +342,8 @@ async function toggleFalseTrigger(id, newValue, btn) {
} }
} }
// ── Event detail modal ─────────────────────────────────────────────────────── // Event detail modal now lives in /static/event-modal.js (shared component).
function showEventDetail(jsonStr) { // `showEventDetail(eventId)` is exposed globally; row onclick handlers call it.
const ev = JSON.parse(jsonStr);
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
document.getElementById('modal-title').textContent = `Event — ${esc(ev.serial)} @ ${ts}`;
document.getElementById('modal-content').innerHTML = `
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
<div><span class="text-gray-500">Serial</span><span class="ml-2 font-medium">${esc(ev.serial)}</span></div>
<div><span class="text-gray-500">Key</span><span class="ml-2 font-mono text-xs">${esc(ev.waveform_key)}</span></div>
<div><span class="text-gray-500">Timestamp</span><span class="ml-2 font-medium">${ts}</span></div>
<div><span class="text-gray-500">Sample Rate</span><span class="ml-2 font-medium">${ev.sample_rate || '—'} sps</span></div>
<div><span class="text-gray-500">Record Type</span><span class="ml-2 font-medium">${ev.record_type || '—'}</span></div>
<div><span class="text-gray-500">False Trigger</span><span class="ml-2 font-medium">${ev.false_trigger ? '⚠ Yes' : 'No'}</span></div>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
<div><span class="text-gray-500">Project</span><span class="ml-2 font-medium">${esc(ev.project || '—')}</span></div>
<div><span class="text-gray-500">Client</span><span class="ml-2 font-medium">${esc(ev.client || '—')}</span></div>
<div><span class="text-gray-500">Operator</span><span class="ml-2 font-medium">${esc(ev.operator || '—')}</span></div>
<div><span class="text-gray-500">Sensor Loc</span><span class="ml-2 font-medium">${esc(ev.sensor_location || '—')}</span></div>
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Peak Particle Velocity</h4>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
${ppvCard('Transverse', ev.tran_ppv)}
${ppvCard('Vertical', ev.vert_ppv)}
${ppvCard('Longitudinal', ev.long_ppv)}
${ppvCard('Peak Vector Sum', ev.peak_vector_sum, true)}
</div>
${ev.mic_ppv != null ? `<div class="mt-3 text-sm text-center text-gray-600 dark:text-gray-400">Mic: <span class="font-mono font-medium">${ev.mic_ppv.toFixed(3)}</span></div>` : ''}
</div>`;
document.getElementById('event-modal').classList.remove('hidden');
}
function ppvCard(label, v, bold = false) {
const val = v != null ? v.toFixed(4) : '—';
const cls = ppvClass(v) + (bold ? ' text-lg' : '');
return `<div>
<div class="text-xs text-gray-500 mb-1">${label}</div>
<div class="font-mono font-${bold ? 'bold' : 'medium'} ${cls}">${val}</div>
<div class="text-xs text-gray-400">in/s</div>
</div>`;
}
function closeEventModal() {
document.getElementById('event-modal').classList.add('hidden');
}
// ── Units tab ──────────────────────────────────────────────────────────────── // ── Units tab ────────────────────────────────────────────────────────────────
async function loadUnits() { async function loadUnits() {
+10 -4
View File
@@ -2221,19 +2221,21 @@ function _ueEsc(s) {
} }
function _ueAttrCell(ev) { function _ueAttrCell(ev) {
// Inline links use onclick="event.stopPropagation()" so clicking the
// project/location link navigates instead of opening the event-detail
// modal (which fires from the row-level onclick).
const a = ev.attribution; const a = ev.attribution;
if (a) { if (a) {
// Attributed: project / location link.
const projLabel = _ueEsc(a.project_name || '—'); const projLabel = _ueEsc(a.project_name || '—');
const locLabel = _ueEsc(a.location_name || '—'); const locLabel = _ueEsc(a.location_name || '—');
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}" return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
onclick="event.stopPropagation()"
class="text-seismo-orange hover:text-seismo-navy" class="text-seismo-orange hover:text-seismo-navy"
title="${projLabel} → ${locLabel}"> title="${projLabel} → ${locLabel}">
📍 ${locLabel} 📍 ${locLabel}
</a> </a>
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`; <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; const n = ev.nearest_assignment;
if (n) { if (n) {
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary'); const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
@@ -2243,7 +2245,7 @@ function _ueAttrCell(ev) {
: `${days.toFixed(1)}d`; : `${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> 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"> <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> ${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" onclick="event.stopPropagation()" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
</div>`; </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>`; 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>`;
@@ -2277,7 +2279,7 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
? '<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>' ? '<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'}"> return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}" onclick="showEventDetail('${_dtEsc(ev.id)}')">
<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 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.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.vert_ppv)}">${vert}</td>
@@ -2854,4 +2856,8 @@ function showToast(message, type = 'info') {
</div> </div>
</div> </div>
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
{% endblock %} {% endblock %}
+6 -2
View File
@@ -679,10 +679,10 @@ function renderEventTable(events, total, container) {
? '<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>' ? '<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"> return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail('${esc(ev.id)}')">
<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 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"> <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> <a href="/unit/${esc(ev.serial)}" class="hover:text-seismo-navy" onclick="event.stopPropagation()">${esc(ev.serial)}</a>
</td> </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.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.vert_ppv)}">${vert}</td>
@@ -953,4 +953,8 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeSwapModal(); if (e.target === this) closeSwapModal();
}); });
</script> </script>
{# Shared event-detail modal (clicking an event row in the Events tab) #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
{% endblock %} {% endblock %}