Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4378290c9c | |||
| 9775dca114 | |||
| 904ff04440 | |||
| 155f0b007a | |||
| 583af1948e | |||
| 449e031589 | |||
| 18fd0472a5 | |||
| e15481884a | |||
| 737901c962 | |||
| 2cf5bf47d3 | |||
| 77483c2186 | |||
| b1c2a1d778 | |||
| d3b5a3fd26 | |||
| d46f9fccf8 | |||
| 6ebbe28308 | |||
| 42de06f441 | |||
| 21844b4d65 | |||
| 80fa76208a |
@@ -5,6 +5,56 @@ All notable changes to Terra-View will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.10.0] - 2026-05-14
|
||||
|
||||
This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.
|
||||
|
||||
### Added
|
||||
- **SFM Integration**: New fleet-wide events page at `/sfm` listing every event ingested by SFM, with filters for serial, date range, false-trigger flag, and limit. Unit detail pages and project-location pages show their own attributed subsets of the same event stream.
|
||||
- **Event Detail Modal**: Shared across `/sfm`, unit detail, and project-location pages — clicking any event opens a rich modal showing peaks per channel (PVS color-coded by magnitude), microphone dB(L) + ZC frequency + time of peak, sensor self-check table with pass/fail per channel, device/recording metadata (firmware, battery, calibration date, geo range), and download buttons for the original Blastware binary and the sidecar JSON. Includes an inline pretty-printed JSON viewer with copy-to-clipboard.
|
||||
- **Events Attribution Engine** (`backend/services/sfm_events.py`): Per-event attribution against `UnitAssignment` time windows. Events outside any assignment window surface in an "Unattributed" bucket with the nearest-assignment diagnostic (which location, signed delta in days).
|
||||
- **Metadata Backfill Tool** (`/tools` → Backfill from event metadata): Scans operator-typed `project` and `sensor_location` strings in event sidecars, fuzzy-clusters them via `rapidfuzz.WRatio`, and proposes retroactive `UnitAssignment` records to attribute orphan events. Tracks operator decisions per cluster across re-scans.
|
||||
- **Project Tidy Tool** (`/tools` → Project Tidy): Fuzzy-detect duplicate projects and bulk-merge them with a single click. Source projects soft-deleted with full audit trail.
|
||||
- **Vibration Summary on Project Pages**: New roll-up card on vibration project detail pages showing per-location event counts, the project's "Overall Peak" PVS (false triggers excluded), last event timestamp, and a Top Locations by Activity list.
|
||||
- **SFM-Primary Seismograph Status**: `emit_status_snapshot()` now consults SFM's `/db/units` (cached 15s) before falling back to `Emitter.last_seen` for each seismograph. The fresher signal wins; the choice is recorded in a new per-unit `last_seen_source` field. A small `SFM` (orange) or `HB` (gray) badge on each unit's active-table row shows which path is currently driving the status.
|
||||
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moved to a horizontal collapsible card below the Fleet Map, auto-expanding only when pending actions exist. Recent Call-Ins now sources from a new `/api/recent-event-callins` endpoint backed by SFM event forwards instead of the watcher-heartbeat endpoint.
|
||||
- **Sortable Events Tables**: `/sfm` and unit-detail SFM Events tables now have clickable column headers with ↕/↓/↑ indicators. Default sort is Timestamp DESC. Click same column to toggle direction; click different column to switch and reset to DESC. Pure client-side over cached rows — no re-fetches.
|
||||
- **Developer → SFM Admin** (`/admin/sfm`): Health banner with reachability indicator, terra-view↔SFM connection panel, 4 KPI tiles (known units, total events, stale `monitor_log` rows, stale `ach_sessions` rows), per-unit roll-up table, recent-events table with color-coded forwarding latency (so stale watcher forwards stand out), and a raw API tester for any `/api/sfm/*` path.
|
||||
- **Developer → SLMM Admin** (`/admin/slmm`): Stripped-down companion page — health, connection info, raw API tester.
|
||||
- **Tools Workflow Hub** (`/tools`): New top-level sidebar entry consolidating Pair Devices, Project Tidy, Metadata Backfill, Reports (info card), and Swap Detection (placeholder).
|
||||
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices) replacing five separate sidebar items.
|
||||
- **Synology Deployment Doc** (`docs/SYNOLOGY_DEPLOYMENT.md`): End-to-end playbook for migrating the stack to an always-on office NAS — phased rollout (pre-stage, data rsync, watcher repoint, external access, decommission), Tailscale vs reverse-proxy options, rollback plan, and gotchas.
|
||||
|
||||
### Changed
|
||||
- **Overall Peak excludes false triggers**: The project-level "Overall Peak" KPI tile (and the underlying `_compute_stats()` function in `sfm_events.py`) now skip events flagged as false triggers when computing the highest PVS, so operators see the highest real event rather than the biggest sensor glitch. `false_trigger_count` still includes flagged events so operators can see how many were filtered out.
|
||||
- **`RosterUnit.note` Editing**: Inline edit on seismograph cards is more forgiving and now auto-saves on blur.
|
||||
- **Sidebar Nav Renamed**: Old "Fleet" sidebar entry → "Devices" (renamed because it always meant the device list, not the broader fleet view).
|
||||
|
||||
### Fixed
|
||||
- **Status drift between watcher heartbeat and actual event arrivals**: Seismographs are now reported with whichever signal is more recent — eliminates the case where a unit had recent SFM events but a stale heartbeat (or vice-versa) showed the wrong status.
|
||||
- **Event modal: Record Type always showed "Waveform"**: Workaround client-side — Record Type now derived from the Blastware filename's last-char code (`H`=Histogram, `W`=Waveform, `M`=Manual, `E`=Event, `C`=Combo). The proper fix lives in SFM's sidecar parser; tracked separately.
|
||||
- **Event modal: Mic PSI tile removed**: Operators only care about dB(L); the redundant PSI tile was dropped.
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying. Every migration is idempotent.
|
||||
|
||||
```bash
|
||||
# Cleanest: re-run all migrations in chronological order.
|
||||
# Already-applied migrations no-op safely.
|
||||
for f in backend/migrate_*.py; do
|
||||
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
||||
done
|
||||
```
|
||||
|
||||
Migrations new in this release:
|
||||
- `migrate_add_metadata_backfill.py` — adds `unit_assignments.source` column and `metadata_backfill_decisions` table for the Metadata Backfill tool
|
||||
|
||||
### Deployment Notes
|
||||
- **`SFM_BASE_URL`**: Confirm prod's `docker-compose.yml` sets this for the terra-view service (typically `http://sfm:8200` for the in-stack SFM container, or an external URL if SFM lives elsewhere).
|
||||
- **Watcher repoint**: series3-watcher's `sfm_forward_url` should point at `https://<your-terra-view-host>/api/sfm` (proxy-based — no second port forward needed). Watcher composes the full path `/db/import/blastware_file` itself.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.4] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Terra-View v0.9.4
|
||||
# Terra-View v0.10.0
|
||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||
|
||||
## Features
|
||||
@@ -496,6 +496,19 @@ docker compose down -v
|
||||
|
||||
## Release Highlights
|
||||
|
||||
### v0.10.0 — 2026-05-14
|
||||
- **SFM Integration**: terra-view now consumes events from the SFM (Seismograph Field Module) backend in real time, with a fleet-wide events page at `/sfm`, per-unit attribution against project assignment windows, and a project-level vibration roll-up that uses SFM data as the single source of truth.
|
||||
- **SFM-Primary Seismograph Status**: Deployed seismograph status (OK/Pending/Missing) now flows from SFM event forwards first; the watcher heartbeat stays as a transparent backup. Each unit's active table row shows a small `SFM` or `HB` badge so operators can see at a glance which signal is currently driving the status.
|
||||
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moves to a horizontal collapsible card below the Fleet Map, auto-expanding only when there's a pending action. Recent Call-Ins now sources from SFM event forwards instead of the legacy watcher-heartbeat endpoint.
|
||||
- **Event Detail Modal**: Click any event anywhere in the app to open a rich detail modal showing peak particle velocity per channel, microphone dB(L), sensor self-check results, device/recording metadata, and download buttons for the original Blastware binary and sidecar JSON. Includes an inline JSON viewer with one-click copy.
|
||||
- **Sortable Events Tables**: Every events table (project events, unit-detail events, fleet-wide /sfm) now supports clickable column-header sorting with directional indicators. Defaults to newest-first.
|
||||
- **Events Attribution & Backfill**: Each SFM event is automatically attributed to a project/location based on `UnitAssignment` time windows. Unattributed events get a diagnostic showing the nearest assignment and a delta-days gap. The metadata-backfill tool in `/tools` scans operator-typed project/sensor-location strings in event sidecars and clusters them via fuzzy matching to propose new assignment retroactives.
|
||||
- **Projects Tools**: New `/tools` workflow hub consolidates Pair Devices, Project Tidy (fuzzy-detect + merge duplicate projects), Metadata Backfill, Reports, and Swap Detection (placeholder).
|
||||
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices).
|
||||
- **Developer → SFM Admin**: New `/admin/sfm` page surfacing SFM health, per-unit roll-up from `/db/units`, recent-events table with forwarding latency (so operators can spot stale watcher forwards), stale-table counts, and a raw API tester. Companion `/admin/slmm` page covers SLMM health + raw API.
|
||||
- **"Overall Peak" excludes False Triggers**: The project-level Overall Peak KPI tile now excludes events flagged as false triggers — operators see the highest real event, not the biggest sensor glitch.
|
||||
- **Synology Deployment Doc**: New `docs/SYNOLOGY_DEPLOYMENT.md` covers migrating the stack to an always-on office NAS, including phased rollout, data rsync, watcher repoint, external-access (Tailscale or reverse-proxy), and rollback plan.
|
||||
|
||||
### v0.8.0 — 2026-03-18
|
||||
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
|
||||
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
|
||||
@@ -599,9 +612,21 @@ MIT
|
||||
|
||||
## Version
|
||||
|
||||
**Current: 0.8.0** — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
|
||||
**Current: 0.10.0** — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
||||
|
||||
Previous: 0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
|
||||
Previous: 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
||||
|
||||
0.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
|
||||
|
||||
0.9.2 — Deployment records, allocated status, quick-info unit modal, inline seismograph editing (2026-03-27)
|
||||
|
||||
0.9.1 — Fix location slots not persisting on save/reload (2026-03-20)
|
||||
|
||||
0.9.0 — Job Planner redesign, monitoring locations, estimated units, smart color picker, calendar bar tooltips, toast notifications (2026-03-19)
|
||||
|
||||
0.8.0 — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
|
||||
|
||||
0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
|
||||
|
||||
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
||||
|
||||
|
||||
+30
-1
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.9.4"
|
||||
VERSION = "0.10.0"
|
||||
if ENVIRONMENT == "development":
|
||||
_build = os.getenv("BUILD_NUMBER", "0")
|
||||
if _build and _build != "0":
|
||||
@@ -106,6 +106,9 @@ app.include_router(settings.router)
|
||||
from backend.routers import watcher_manager
|
||||
app.include_router(watcher_manager.router)
|
||||
|
||||
from backend.routers import admin_modules
|
||||
app.include_router(admin_modules.router)
|
||||
|
||||
# Projects system routers
|
||||
app.include_router(projects.router)
|
||||
app.include_router(project_locations.router)
|
||||
@@ -115,6 +118,10 @@ app.include_router(scheduler.router)
|
||||
from backend.routers import report_templates
|
||||
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
|
||||
from backend.routers import alerts
|
||||
app.include_router(alerts.router)
|
||||
@@ -240,6 +247,28 @@ async def sfm_page(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("/settings/developer/project-tidy", response_class=HTMLResponse)
|
||||
async def project_tidy_page(request: Request):
|
||||
"""Tidy duplicate-looking projects: detect by fuzzy name match, merge
|
||||
by clicking through pairs (Phase 5b)."""
|
||||
return templates.TemplateResponse("admin/project_tidy.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/tools", response_class=HTMLResponse)
|
||||
async def tools_page(request: Request):
|
||||
"""Tools / workflow hub. Active operator workflows (device pairing,
|
||||
project tidy, metadata backfill, future swap detection, report
|
||||
generators) all live here in card form."""
|
||||
return templates.TemplateResponse("tools.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/modems", response_class=HTMLResponse)
|
||||
async def modems_page(request: Request):
|
||||
"""Field modems management dashboard"""
|
||||
|
||||
@@ -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()
|
||||
@@ -259,9 +259,48 @@ class UnitAssignment(Base):
|
||||
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Scheduled actions: automation for recording start/stop/download.
|
||||
|
||||
@@ -4,11 +4,29 @@ from sqlalchemy import desc
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
from backend.database import get_db
|
||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["activity"])
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
|
||||
def _humanize_age(seconds: float) -> str:
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
if seconds < 3600:
|
||||
return f"{int(seconds / 60)}m ago"
|
||||
if seconds < 86400:
|
||||
hrs = seconds / 3600
|
||||
return f"{int(hrs)}h {int((hrs % 1) * 60)}m ago"
|
||||
return f"{int(seconds / 86400)}d ago"
|
||||
|
||||
PHOTOS_BASE_DIR = Path("data/photos")
|
||||
|
||||
|
||||
@@ -144,3 +162,86 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
||||
"hours": hours,
|
||||
"time_threshold": time_threshold.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recent-event-callins")
|
||||
async def get_recent_event_callins(limit: int = 10, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Recent unit call-ins derived from SFM event forwards.
|
||||
|
||||
Architecture context: the live ACH replacement is on hold, so call-homes
|
||||
arrive as Blastware ACH event files forwarded by series3-watcher and
|
||||
landed in the SFM events store. One event ≈ one call-in. This is the
|
||||
forward-looking source of "recent call-ins" that will eventually replace
|
||||
the heartbeat-based /recent-callins endpoint entirely.
|
||||
|
||||
Each row represents one event; multiple consecutive events from the same
|
||||
serial are intentionally NOT collapsed — each one is a distinct call-home.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{SFM_BASE_URL}/db/events",
|
||||
params={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/events failed for recent-event-callins: %s", e)
|
||||
return {"call_ins": [], "total": 0, "error": str(e)}
|
||||
|
||||
events = payload.get("events", []) or []
|
||||
|
||||
# Bulk-resolve serials → roster (single query, no N+1)
|
||||
serials = list({ev.get("serial") for ev in events if ev.get("serial")})
|
||||
roster_map: Dict[str, RosterUnit] = {}
|
||||
if serials:
|
||||
roster_map = {
|
||||
r.id: r
|
||||
for r in db.query(RosterUnit).filter(RosterUnit.id.in_(serials)).all()
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
call_ins: List[Dict[str, Any]] = []
|
||||
|
||||
for ev in events:
|
||||
serial = ev.get("serial")
|
||||
if not serial:
|
||||
continue
|
||||
|
||||
roster = roster_map.get(serial)
|
||||
|
||||
# created_at = when SFM received the forward. Falls back to the event
|
||||
# timestamp if the SFM payload didn't carry created_at (older rows).
|
||||
created_at_str = ev.get("created_at") or ev.get("timestamp")
|
||||
time_ago = "—"
|
||||
if created_at_str:
|
||||
try:
|
||||
ts = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
time_ago = _humanize_age((now - ts).total_seconds())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
call_ins.append({
|
||||
"unit_id": serial,
|
||||
"serial": serial,
|
||||
"event_id": ev.get("id"),
|
||||
"event_timestamp": ev.get("timestamp"),
|
||||
"created_at": ev.get("created_at"),
|
||||
"time_ago": time_ago,
|
||||
"peak_vector_sum": ev.get("peak_vector_sum"),
|
||||
"false_trigger": bool(ev.get("false_trigger")),
|
||||
"sensor_location": ev.get("sensor_location") or "",
|
||||
"project": ev.get("project") or "",
|
||||
"device_type": roster.device_type if roster else "seismograph",
|
||||
"in_roster": roster is not None,
|
||||
"note": (roster.note if roster else "") or "",
|
||||
})
|
||||
|
||||
return {
|
||||
"call_ins": call_ins,
|
||||
"total": len(call_ins),
|
||||
"source": "sfm-events",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Admin / diagnostic pages for the device modules (SFM, SLMM).
|
||||
|
||||
These pages live under /admin/{module} and exist purely so an operator can
|
||||
peek under the hood and confirm the module is reachable, what data it's
|
||||
holding, and whether the proxy from terra-view is healthy.
|
||||
|
||||
Routes:
|
||||
GET /admin/sfm — SFM diagnostic page
|
||||
GET /admin/slmm — SLMM diagnostic page
|
||||
|
||||
API helpers (called by the HTML pages via fetch):
|
||||
GET /api/admin/sfm/overview — aggregated SFM health + db stats in one call
|
||||
GET /api/admin/slmm/overview — aggregated SLMM health + device count
|
||||
|
||||
The pages are intentionally read-only. Any actual administration of SFM
|
||||
or SLMM happens in those modules directly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.templates_config import templates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
# ── SFM ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/admin/sfm", response_class=HTMLResponse)
|
||||
def admin_sfm_page(request: Request):
|
||||
return templates.TemplateResponse("admin_sfm.html", {
|
||||
"request": request,
|
||||
"sfm_base_url": SFM_BASE_URL,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/admin/sfm/overview")
|
||||
async def admin_sfm_overview() -> JSONResponse:
|
||||
"""Aggregated SFM diagnostic snapshot.
|
||||
|
||||
Returns health, db stats, stale-table counts, per-unit summary, and
|
||||
recent events with forwarding latency. Tolerant of partial failures:
|
||||
any individual sub-fetch error is captured into its section, so a flaky
|
||||
sub-endpoint doesn't break the whole page.
|
||||
"""
|
||||
overview: Dict[str, Any] = {
|
||||
"sfm_base_url": SFM_BASE_URL,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"health": None,
|
||||
"reachable": False,
|
||||
"units": [],
|
||||
"events": [],
|
||||
"stale": {
|
||||
"monitor_log": None,
|
||||
"sessions": None,
|
||||
},
|
||||
"cache_stats": None,
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Health
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/health")
|
||||
r.raise_for_status()
|
||||
overview["health"] = r.json()
|
||||
overview["reachable"] = overview["health"].get("status") == "ok"
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["health"] = str(e)
|
||||
overview["reachable"] = False
|
||||
|
||||
# If SFM is down, no point hitting the rest.
|
||||
if not overview["reachable"]:
|
||||
return JSONResponse(overview)
|
||||
|
||||
# Units
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/db/units")
|
||||
r.raise_for_status()
|
||||
overview["units"] = r.json() or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["units"] = str(e)
|
||||
|
||||
# Recent events (newest 25 — bigger sample of the call-home stream)
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/db/events", params={"limit": 25})
|
||||
r.raise_for_status()
|
||||
payload = r.json() or {}
|
||||
events = payload.get("events", []) or []
|
||||
# Compute forwarding latency: created_at (SFM ingest) − timestamp (event).
|
||||
now = datetime.now(timezone.utc)
|
||||
for ev in events:
|
||||
ev.pop("waveform_blob", None)
|
||||
ev.pop("a5_pickle_filename", None)
|
||||
ts_str = ev.get("timestamp")
|
||||
ca_str = ev.get("created_at")
|
||||
latency_seconds = None
|
||||
try:
|
||||
if ts_str and ca_str:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
ca = datetime.fromisoformat(ca_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc)
|
||||
if ca.tzinfo is None: ca = ca.replace(tzinfo=timezone.utc)
|
||||
latency_seconds = (ca - ts).total_seconds()
|
||||
except ValueError:
|
||||
pass
|
||||
ev["forwarding_latency_seconds"] = latency_seconds
|
||||
overview["events"] = events
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["events"] = str(e)
|
||||
|
||||
# Stale tables (deprecated by the watcher-forward pipeline but still
|
||||
# present in SFM's SQLite). Surface as counts only.
|
||||
for key, path in (("monitor_log", "/db/monitor_log"),
|
||||
("sessions", "/db/sessions")):
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}{path}", params={"limit": 1})
|
||||
r.raise_for_status()
|
||||
payload = r.json() or {}
|
||||
# SFM returns count = total when limit covers all rows; we
|
||||
# query with limit=1 just to be polite, then ask again with
|
||||
# a high limit if we need the real total.
|
||||
first_count = payload.get("count")
|
||||
if first_count is None:
|
||||
overview["stale"][key] = None
|
||||
continue
|
||||
# Re-query with high limit to get the true total.
|
||||
r2 = await client.get(f"{SFM_BASE_URL}{path}", params={"limit": 100000})
|
||||
r2.raise_for_status()
|
||||
overview["stale"][key] = (r2.json() or {}).get("count")
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"][f"stale_{key}"] = str(e)
|
||||
|
||||
# Cache stats (in-memory device cache on SFM)
|
||||
try:
|
||||
r = await client.get(f"{SFM_BASE_URL}/cache/stats")
|
||||
r.raise_for_status()
|
||||
overview["cache_stats"] = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["cache_stats"] = str(e)
|
||||
|
||||
# Aggregate counts the UI can render without re-walking arrays
|
||||
overview["totals"] = {
|
||||
"units": len(overview["units"]),
|
||||
"events_total": sum(u.get("total_events", 0) for u in overview["units"]),
|
||||
"stale_monitor_log": overview["stale"]["monitor_log"],
|
||||
"stale_sessions": overview["stale"]["sessions"],
|
||||
}
|
||||
|
||||
return JSONResponse(overview)
|
||||
|
||||
|
||||
# ── SLMM ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/admin/slmm", response_class=HTMLResponse)
|
||||
def admin_slmm_page(request: Request):
|
||||
return templates.TemplateResponse("admin_slmm.html", {
|
||||
"request": request,
|
||||
"slmm_base_url": SLMM_BASE_URL,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/admin/slmm/overview")
|
||||
async def admin_slmm_overview() -> JSONResponse:
|
||||
"""Aggregated SLMM diagnostic snapshot."""
|
||||
overview: Dict[str, Any] = {
|
||||
"slmm_base_url": SLMM_BASE_URL,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"health": None,
|
||||
"reachable": False,
|
||||
"devices": [],
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
r = await client.get(f"{SLMM_BASE_URL}/health")
|
||||
r.raise_for_status()
|
||||
overview["health"] = r.json()
|
||||
overview["reachable"] = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
overview["errors"]["health"] = str(e)
|
||||
return JSONResponse(overview)
|
||||
|
||||
# Pull a roster of configured devices (SLMM exposes per-unit
|
||||
# config + status under /api/nl43/*). This is a best-effort probe
|
||||
# — SLMM doesn't expose a "list all devices" endpoint, so we ask
|
||||
# terra-view's RosterUnit table what serials it knows about for
|
||||
# SLMs and just check each one. For now, just surface the health
|
||||
# payload and let the operator click through to /sound-level-meters
|
||||
# for the per-device details.
|
||||
|
||||
return JSONResponse(overview)
|
||||
@@ -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},
|
||||
}
|
||||
@@ -688,6 +688,115 @@ async def restore_project(project_id: str, db: Session = Depends(get_db)):
|
||||
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.get("/admin/duplicate_pairs")
|
||||
async def get_duplicate_pairs(
|
||||
threshold: float = 0.85,
|
||||
max_pairs: int = 200,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return all active-project pairs whose names fuzzy-match above the
|
||||
threshold. Used by the Tidy page to surface duplicates that would
|
||||
otherwise have to be hunted down one at a time.
|
||||
|
||||
Each pair carries a suggested merge-target with the reasoning so the
|
||||
operator can decide direction with one click.
|
||||
"""
|
||||
from backend.services import project_tidy as pt
|
||||
pairs = pt.find_duplicate_pairs(db, threshold=threshold, max_pairs=max_pairs)
|
||||
|
||||
def _ps(p):
|
||||
return {
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"project_number": p.project_number,
|
||||
"client_name": p.client_name,
|
||||
"source": p.source,
|
||||
"status": p.status,
|
||||
"location_count": p.location_count,
|
||||
"assignment_count": p.assignment_count,
|
||||
}
|
||||
|
||||
return {
|
||||
"pairs": [
|
||||
{
|
||||
"a": _ps(pair.a),
|
||||
"b": _ps(pair.b),
|
||||
"score": round(pair.score, 3),
|
||||
"suggested_target_id": pair.suggested_target_id,
|
||||
"reason": pair.reason,
|
||||
}
|
||||
for pair in pairs
|
||||
],
|
||||
"threshold": threshold,
|
||||
}
|
||||
|
||||
|
||||
@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}")
|
||||
async def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
project_tidy.py — find duplicate-looking projects + offer bulk merge.
|
||||
|
||||
The metadata-backfill parser is good at clustering events into candidate
|
||||
projects but doesn't compare its proposed project names against EACH OTHER
|
||||
(it only checks against existing terra-view projects). After a bulk
|
||||
apply, you can end up with many near-duplicate projects — typo variants,
|
||||
abbreviation differences, etc. This module surfaces them as pairs the
|
||||
operator can merge.
|
||||
|
||||
Pairs vs clusters: a fully-connected group like (A, B, C) where each pair
|
||||
scores >= threshold becomes 3 pairs. The operator has to do 2 merges to
|
||||
fully consolidate. We don't try to be smarter about transitive grouping —
|
||||
in practice operators want to review the highest-similarity pair first
|
||||
anyway, and the list re-computes after each merge.
|
||||
|
||||
Public API:
|
||||
find_duplicate_pairs(db, *, threshold=0.85, max_pairs=200) → list[DuplicatePair]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import rapidfuzz
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import (
|
||||
Project,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
)
|
||||
from backend.services.metadata_backfill import _normalise as _meta_normalise
|
||||
|
||||
log = logging.getLogger("backend.services.project_tidy")
|
||||
|
||||
|
||||
DEFAULT_THRESHOLD = 0.85 # WRatio similarity above which we surface a pair
|
||||
DEFAULT_MAX_PAIRS = 200 # Cap the result list to keep response small
|
||||
MIN_NORMALISED_LENGTH = 4 # Skip projects whose normalised name is too short
|
||||
# to fuzzy-match safely (avoids "1" / "1" pairs).
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectSummary:
|
||||
id: str
|
||||
name: str
|
||||
project_number: Optional[str]
|
||||
client_name: Optional[str]
|
||||
source: str # 'manual' | 'metadata_backfill' | ...
|
||||
status: str
|
||||
location_count: int
|
||||
assignment_count: int
|
||||
event_count_total: int # approx — sum across assignments
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplicatePair:
|
||||
a: ProjectSummary
|
||||
b: ProjectSummary
|
||||
score: float
|
||||
suggested_target_id: str # the recommended "keep" side
|
||||
reason: str # why we picked that target
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalise_project_name(name: str) -> str:
|
||||
"""Project-name normalisation for tidy comparison.
|
||||
|
||||
Reuses the metadata_backfill normaliser (lowercase, punctuation→space,
|
||||
collapse whitespace). Returns "" for None or all-punctuation names.
|
||||
"""
|
||||
return _meta_normalise(name)
|
||||
|
||||
|
||||
def _summarise_projects(db: Session) -> list[ProjectSummary]:
|
||||
"""One row per active project with cached counts. Excludes deleted."""
|
||||
projects = (
|
||||
db.query(Project)
|
||||
.filter(Project.status != "deleted")
|
||||
.all()
|
||||
)
|
||||
|
||||
# Bulk lookup: assignment counts + location counts per project.
|
||||
loc_counts: dict[str, int] = dict(
|
||||
db.query(MonitoringLocation.project_id, func.count(MonitoringLocation.id))
|
||||
.filter(MonitoringLocation.project_id.in_([p.id for p in projects]) if projects else False)
|
||||
.group_by(MonitoringLocation.project_id)
|
||||
.all()
|
||||
)
|
||||
asgn_counts: dict[str, int] = dict(
|
||||
db.query(UnitAssignment.project_id, func.count(UnitAssignment.id))
|
||||
.filter(UnitAssignment.project_id.in_([p.id for p in projects]) if projects else False)
|
||||
.group_by(UnitAssignment.project_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
summaries: list[ProjectSummary] = []
|
||||
for p in projects:
|
||||
summaries.append(ProjectSummary(
|
||||
id = p.id,
|
||||
name = p.name,
|
||||
project_number = p.project_number,
|
||||
client_name = p.client_name,
|
||||
source = None, # filled below per assignment
|
||||
status = p.status or "active",
|
||||
location_count = loc_counts.get(p.id, 0),
|
||||
assignment_count = asgn_counts.get(p.id, 0),
|
||||
event_count_total = 0, # not cheap to compute here; left 0
|
||||
))
|
||||
|
||||
# Determine each project's dominant assignment source. Used to break ties
|
||||
# when picking the "keep" target — prefer manual over parser-created.
|
||||
rows = (
|
||||
db.query(UnitAssignment.project_id, UnitAssignment.source, func.count(UnitAssignment.id))
|
||||
.group_by(UnitAssignment.project_id, UnitAssignment.source)
|
||||
.all()
|
||||
)
|
||||
by_proj_src: dict[str, dict[str, int]] = {}
|
||||
for proj_id, src, cnt in rows:
|
||||
by_proj_src.setdefault(proj_id, {})[src or "manual"] = cnt
|
||||
for s in summaries:
|
||||
src_map = by_proj_src.get(s.id, {})
|
||||
if not src_map:
|
||||
s.source = "manual"
|
||||
else:
|
||||
# Dominant source (most assignments).
|
||||
s.source = max(src_map.items(), key=lambda kv: kv[1])[0]
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
def _pick_target(a: ProjectSummary, b: ProjectSummary) -> tuple[str, str]:
|
||||
"""Decide which project should be the merge target (the one we keep).
|
||||
|
||||
Priorities (in order):
|
||||
1. The one with `source='manual'` over `source='metadata_backfill'`
|
||||
— operator-curated projects beat parser-created ones.
|
||||
2. The one with a populated `project_number`.
|
||||
3. The one with more locations (more curation history).
|
||||
4. The one with more assignments.
|
||||
5. The one with the shorter, cleaner name (tiebreaker).
|
||||
|
||||
Returns (target_id, reason_string).
|
||||
"""
|
||||
# 1. Source provenance.
|
||||
a_manual = a.source == "manual"
|
||||
b_manual = b.source == "manual"
|
||||
if a_manual and not b_manual:
|
||||
return a.id, "A is manually-created; B is parser-created"
|
||||
if b_manual and not a_manual:
|
||||
return b.id, "B is manually-created; A is parser-created"
|
||||
|
||||
# 2. project_number populated.
|
||||
if a.project_number and not b.project_number:
|
||||
return a.id, "A has a project_number; B doesn't"
|
||||
if b.project_number and not a.project_number:
|
||||
return b.id, "B has a project_number; A doesn't"
|
||||
|
||||
# 3. More locations.
|
||||
if a.location_count > b.location_count:
|
||||
return a.id, f"A has more locations ({a.location_count} vs {b.location_count})"
|
||||
if b.location_count > a.location_count:
|
||||
return b.id, f"B has more locations ({b.location_count} vs {a.location_count})"
|
||||
|
||||
# 4. More assignments.
|
||||
if a.assignment_count > b.assignment_count:
|
||||
return a.id, f"A has more assignments ({a.assignment_count} vs {b.assignment_count})"
|
||||
if b.assignment_count > a.assignment_count:
|
||||
return b.id, f"B has more assignments ({b.assignment_count} vs {a.assignment_count})"
|
||||
|
||||
# 5. Shorter name (less likely to have baked-in junk).
|
||||
if len(a.name) <= len(b.name):
|
||||
return a.id, "A has the shorter / cleaner name"
|
||||
return b.id, "B has the shorter / cleaner name"
|
||||
|
||||
|
||||
# ── Public ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_duplicate_pairs(
|
||||
db: Session,
|
||||
*,
|
||||
threshold: float = DEFAULT_THRESHOLD,
|
||||
max_pairs: int = DEFAULT_MAX_PAIRS,
|
||||
) -> list[DuplicatePair]:
|
||||
"""Compute all project-pair similarities above `threshold`.
|
||||
|
||||
O(N^2) over the project count — fine up to ~500 projects; beyond that
|
||||
we'd want a blocked / token-indexed approach. In practice
|
||||
`metadata_backfill` projects tend to share tokens, so a simple
|
||||
pre-filter (skip pairs that share NO tokens) would cheaply cut the
|
||||
inner loop. Deferred until profiling motivates it.
|
||||
"""
|
||||
summaries = _summarise_projects(db)
|
||||
|
||||
# Pre-compute normalised names; skip too-short ones.
|
||||
norm_by_id: dict[str, str] = {}
|
||||
candidates: list[ProjectSummary] = []
|
||||
for s in summaries:
|
||||
n = _normalise_project_name(s.name)
|
||||
if len(n) < MIN_NORMALISED_LENGTH:
|
||||
continue
|
||||
norm_by_id[s.id] = n
|
||||
candidates.append(s)
|
||||
|
||||
pairs: list[DuplicatePair] = []
|
||||
n = len(candidates)
|
||||
for i in range(n):
|
||||
a = candidates[i]
|
||||
a_norm = norm_by_id[a.id]
|
||||
for j in range(i + 1, n):
|
||||
b = candidates[j]
|
||||
b_norm = norm_by_id[b.id]
|
||||
score = rapidfuzz.fuzz.WRatio(a_norm, b_norm) / 100.0
|
||||
if score < threshold:
|
||||
continue
|
||||
target_id, reason = _pick_target(a, b)
|
||||
pairs.append(DuplicatePair(
|
||||
a = a,
|
||||
b = b,
|
||||
score = score,
|
||||
suggested_target_id = target_id,
|
||||
reason = reason,
|
||||
))
|
||||
|
||||
# Sort by score desc, then by total content (more data → review first).
|
||||
pairs.sort(key=lambda p: (-p.score, -(p.a.assignment_count + p.b.assignment_count)))
|
||||
|
||||
return pairs[:max_pairs]
|
||||
@@ -548,7 +548,14 @@ def _empty_stats() -> dict:
|
||||
|
||||
|
||||
def _compute_stats(events: list[dict]) -> dict:
|
||||
"""Roll up summary stats from a merged event list. Cheap O(N) pass."""
|
||||
"""Roll up summary stats from a merged event list. Cheap O(N) pass.
|
||||
|
||||
The "Overall Peak" stat (peak_pvs) EXCLUDES events flagged as false
|
||||
triggers — operators care about the highest REAL event, not the
|
||||
biggest sensor glitch. false_trigger_count still includes them so
|
||||
operators can see how many were filtered out. last_event uses
|
||||
every event regardless (it's about activity recency, not magnitude).
|
||||
"""
|
||||
if not events:
|
||||
return _empty_stats()
|
||||
|
||||
@@ -559,19 +566,22 @@ def _compute_stats(events: list[dict]) -> dict:
|
||||
false_trigger_count = 0
|
||||
|
||||
for ev in events:
|
||||
pvs = ev.get("peak_vector_sum")
|
||||
if pvs is not None and (peak_pvs is None or pvs > peak_pvs):
|
||||
peak_pvs = pvs
|
||||
peak_pvs_at = ev.get("timestamp")
|
||||
peak_pvs_serial = ev.get("serial")
|
||||
is_false_trigger = bool(ev.get("false_trigger"))
|
||||
if is_false_trigger:
|
||||
false_trigger_count += 1
|
||||
|
||||
# Peak calculation: skip flagged false triggers.
|
||||
if not is_false_trigger:
|
||||
pvs = ev.get("peak_vector_sum")
|
||||
if pvs is not None and (peak_pvs is None or pvs > peak_pvs):
|
||||
peak_pvs = pvs
|
||||
peak_pvs_at = ev.get("timestamp")
|
||||
peak_pvs_serial = ev.get("serial")
|
||||
|
||||
ts = ev.get("timestamp")
|
||||
if ts and (last_event is None or ts > last_event):
|
||||
last_event = ts
|
||||
|
||||
if ev.get("false_trigger"):
|
||||
false_trigger_count += 1
|
||||
|
||||
return {
|
||||
"event_count": len(events),
|
||||
"peak_pvs": peak_pvs,
|
||||
|
||||
@@ -1,9 +1,77 @@
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db_session
|
||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
# Tiny module-level cache: /api/status-snapshot is polled every 10s by the
|
||||
# dashboard, and we don't want to hammer SFM with one /db/units roundtrip per
|
||||
# call. 15s TTL keeps the cache mostly hot, with occasional refreshes.
|
||||
_SFM_CACHE_TTL_SECONDS = 15.0
|
||||
_sfm_cache_lock = threading.Lock()
|
||||
_sfm_cache: dict = {"fetched_at": 0.0, "data": None, "reachable": False}
|
||||
|
||||
|
||||
def _parse_sfm_timestamp(ts_str: Optional[str]) -> Optional[datetime]:
|
||||
"""SFM /db/units returns naive ISO timestamps (no tz suffix). Treat them
|
||||
as UTC, mirroring how the watcher heartbeat stores Emitter.last_seen."""
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
return ts
|
||||
|
||||
|
||||
def fetch_sfm_unit_last_seen() -> tuple[dict[str, datetime], bool]:
|
||||
"""Return ({serial: last_seen_utc}, sfm_reachable).
|
||||
|
||||
Cached for _SFM_CACHE_TTL_SECONDS. On any HTTP error returns ({}, False)
|
||||
so callers transparently fall back to the watcher-heartbeat path.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with _sfm_cache_lock:
|
||||
if _sfm_cache["data"] is not None and (now - _sfm_cache["fetched_at"]) < _SFM_CACHE_TTL_SECONDS:
|
||||
return _sfm_cache["data"], _sfm_cache["reachable"]
|
||||
|
||||
data: dict[str, datetime] = {}
|
||||
reachable = False
|
||||
try:
|
||||
with httpx.Client(timeout=4.0) as client:
|
||||
resp = client.get(f"{SFM_BASE_URL}/db/units")
|
||||
resp.raise_for_status()
|
||||
payload = resp.json() or []
|
||||
for row in payload:
|
||||
serial = row.get("serial")
|
||||
ts = _parse_sfm_timestamp(row.get("last_seen"))
|
||||
if serial and ts is not None:
|
||||
data[serial] = ts
|
||||
reachable = True
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/units unreachable for status snapshot: %s", e)
|
||||
except Exception as e: # noqa: BLE001 — defensive against malformed payload
|
||||
log.warning("SFM /db/units parse error: %s", e)
|
||||
|
||||
with _sfm_cache_lock:
|
||||
_sfm_cache["fetched_at"] = now
|
||||
_sfm_cache["data"] = data
|
||||
_sfm_cache["reachable"] = reachable
|
||||
return data, reachable
|
||||
|
||||
|
||||
def ensure_utc(dt):
|
||||
if dt is None:
|
||||
@@ -69,6 +137,11 @@ def emit_status_snapshot():
|
||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||
|
||||
# SFM event-forwards are now the primary "last seen" signal for
|
||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
||||
sfm_last_seen_map, sfm_reachable = fetch_sfm_unit_last_seen()
|
||||
|
||||
units = {}
|
||||
|
||||
# --- Merge roster entries first ---
|
||||
@@ -93,24 +166,49 @@ def emit_status_snapshot():
|
||||
last_seen = None
|
||||
fname = ""
|
||||
else:
|
||||
if e:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
# RECALCULATE status based on current time, not stored value
|
||||
device_type = r.device_type or "seismograph"
|
||||
emitter_last_seen = ensure_utc(e.last_seen) if e else None
|
||||
fname = e.last_file if e else ""
|
||||
|
||||
# SFM-primary, heartbeat-backup logic — only for seismographs.
|
||||
# (SLMs / modems aren't forwarded into SFM's events store.)
|
||||
sfm_last_seen = sfm_last_seen_map.get(unit_id) if device_type == "seismograph" else None
|
||||
|
||||
if sfm_last_seen and emitter_last_seen:
|
||||
# Both sources reported — use whichever is more recent.
|
||||
if sfm_last_seen >= emitter_last_seen:
|
||||
last_seen = sfm_last_seen
|
||||
last_seen_source = "sfm"
|
||||
else:
|
||||
last_seen = emitter_last_seen
|
||||
last_seen_source = "heartbeat"
|
||||
elif sfm_last_seen:
|
||||
last_seen = sfm_last_seen
|
||||
last_seen_source = "sfm"
|
||||
elif emitter_last_seen:
|
||||
last_seen = emitter_last_seen
|
||||
# If SFM was reachable but doesn't have this serial, it
|
||||
# means the unit is calling home to the watcher but not
|
||||
# being forwarded — still a working state for now.
|
||||
last_seen_source = "heartbeat"
|
||||
else:
|
||||
last_seen = None
|
||||
last_seen_source = "none"
|
||||
|
||||
if last_seen is not None:
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
age = format_age(last_seen)
|
||||
fname = e.last_file
|
||||
else:
|
||||
# Rostered but no emitter data
|
||||
status = "Missing"
|
||||
last_seen = None
|
||||
age = "N/A"
|
||||
fname = ""
|
||||
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": age,
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"last_seen_source": last_seen_source,
|
||||
"sfm_reachable": sfm_reachable,
|
||||
"fname": fname,
|
||||
"deployed": r.deployed,
|
||||
"note": r.note or "",
|
||||
@@ -136,14 +234,23 @@ def emit_status_snapshot():
|
||||
# --- Add unexpected emitter-only units ---
|
||||
for unit_id, e in emitters.items():
|
||||
if unit_id not in roster:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
emitter_last_seen = ensure_utc(e.last_seen)
|
||||
sfm_last_seen = sfm_last_seen_map.get(unit_id)
|
||||
if sfm_last_seen and (not emitter_last_seen or sfm_last_seen >= emitter_last_seen):
|
||||
last_seen = sfm_last_seen
|
||||
last_seen_source = "sfm"
|
||||
else:
|
||||
last_seen = emitter_last_seen
|
||||
last_seen_source = "heartbeat"
|
||||
# RECALCULATE status for unknown units too
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": format_age(last_seen),
|
||||
"last": last_seen.isoformat(),
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"last_seen_source": last_seen_source,
|
||||
"sfm_reachable": sfm_reachable,
|
||||
"fname": e.last_file,
|
||||
"deployed": False, # default
|
||||
"note": "",
|
||||
@@ -192,6 +299,7 @@ def emit_status_snapshot():
|
||||
unit_data["status"] = paired_unit.get("status", "Missing")
|
||||
unit_data["age"] = paired_unit.get("age", "N/A")
|
||||
unit_data["last"] = paired_unit.get("last")
|
||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||
unit_data["derived_from"] = paired_unit_id
|
||||
|
||||
# Separate buckets for UI
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 _deriveRecordType(filename, fallback) {
|
||||
// SFM currently hardcodes record_type="Waveform" for every event.
|
||||
// The actual type is encoded in the LAST character of the Blastware
|
||||
// filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram).
|
||||
// We derive it client-side until SFM is fixed; if the suffix isn't
|
||||
// a known code we fall back to whatever SFM reported.
|
||||
if (!filename) return fallback || '—';
|
||||
const dotIdx = filename.lastIndexOf('.');
|
||||
if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—';
|
||||
const ext = filename.slice(dotIdx + 1);
|
||||
const lastChar = ext.slice(-1).toUpperCase();
|
||||
const typeMap = {
|
||||
'H': 'Histogram',
|
||||
'W': 'Waveform',
|
||||
'M': 'Manual',
|
||||
'E': 'Event',
|
||||
'C': 'Combo',
|
||||
};
|
||||
return typeMap[lastChar] || (fallback || '—');
|
||||
}
|
||||
|
||||
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 bw = s.blastware || {};
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type);
|
||||
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(recType)}</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 _renderUserNotes(s) {
|
||||
// The "user notes" metadata the operator typed into the BW device.
|
||||
// These are the strings the future metadata-driven parser will use.
|
||||
// NOTE: SFM's sidecar JSON still names this block `project_info` —
|
||||
// we render it as "User Notes" (the actual BW term) but read the
|
||||
// field by its SFM-API name. Rename in SFM is a future cleanup.
|
||||
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) {
|
||||
// Operators only care about dB(L); PSI tile was dropped 2026-05.
|
||||
// We still render the row if any mic data is present so ZC freq /
|
||||
// time-of-peak stay visible even when bw_report.mic is missing.
|
||||
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 zcHz = mic?.zc_freq_hz;
|
||||
const tPk = mic?.time_of_peak_s;
|
||||
const wt = mic?.weighting;
|
||||
|
||||
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
|
||||
${_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>
|
||||
`}
|
||||
<button type="button"
|
||||
onclick="window.toggleEventJsonViewer()"
|
||||
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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
<span id="event-json-toggle-label">View JSON</span>
|
||||
</button>
|
||||
<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>
|
||||
<div id="event-json-viewer" class="hidden mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
||||
<button type="button" onclick="window.copyEventJson()"
|
||||
class="text-xs text-seismo-orange hover:text-seismo-navy">
|
||||
<span id="event-json-copy-label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre id="event-json-pre" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-4 rounded-lg max-h-96 overflow-auto whitespace-pre">${_esc(JSON.stringify(s, null, 2))}</pre>
|
||||
</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('User Notes')}
|
||||
${_renderUserNotes(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');
|
||||
};
|
||||
|
||||
window.toggleEventJsonViewer = function () {
|
||||
const viewer = document.getElementById('event-json-viewer');
|
||||
const label = document.getElementById('event-json-toggle-label');
|
||||
if (!viewer) return;
|
||||
const isHidden = viewer.classList.toggle('hidden');
|
||||
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
||||
};
|
||||
|
||||
window.copyEventJson = function () {
|
||||
const pre = document.getElementById('event-json-pre');
|
||||
const label = document.getElementById('event-json-copy-label');
|
||||
if (!pre) return;
|
||||
navigator.clipboard.writeText(pre.textContent).then(() => {
|
||||
if (label) {
|
||||
label.textContent = 'Copied!';
|
||||
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('clipboard write failed', err);
|
||||
if (label) {
|
||||
label.textContent = 'Failed';
|
||||
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Close on Escape.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') window.closeEventDetailModal();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,436 @@
|
||||
# Synology NAS Deployment Guide
|
||||
|
||||
This guide covers migrating the terra-view stack from a generic Linux host
|
||||
(currently the home server at `10.0.0.44`) to an always-on Synology NAS in
|
||||
the office, including data migration and the minimal external-access
|
||||
networking layer.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture overview](#architecture-overview)
|
||||
2. [Pre-requisites](#pre-requisites)
|
||||
3. [Phase 1 — Pre-stage on the NAS (no downtime)](#phase-1--pre-stage-on-the-nas-no-downtime)
|
||||
4. [Phase 2 — Data migration (~10 min window)](#phase-2--data-migration-10-min-window)
|
||||
5. [Phase 3 — Repoint the watcher (download2-PC)](#phase-3--repoint-the-watcher-download2-pc)
|
||||
6. [Phase 4 — External access for remote operators](#phase-4--external-access-for-remote-operators)
|
||||
7. [Phase 5 — Decommission home server](#phase-5--decommission-home-server)
|
||||
8. [Verification checklist](#verification-checklist)
|
||||
9. [Rollback plan](#rollback-plan)
|
||||
10. [Gotchas](#gotchas)
|
||||
|
||||
---
|
||||
|
||||
## Architecture overview
|
||||
|
||||
The terra-view stack is three containers:
|
||||
|
||||
| Service | Port | What writes to it | Where it lives |
|
||||
|---------------|-------|-----------------------------|----------------|
|
||||
| terra-view | 8001 | Operators (UI), watchers (heartbeat) | Synology NAS |
|
||||
| SFM | 8200 | Watchers (Blastware ACH forwards) | Synology NAS |
|
||||
| SLMM | 8100 | terra-view (proxied), SLMs on LAN | Synology NAS |
|
||||
|
||||
Everything that **writes** to the stack lives inside the office LAN:
|
||||
|
||||
- **download2-PC** is the series3-watcher host. It has a static office IP and
|
||||
POSTs to terra-view's heartbeat endpoint plus SFM's Blastware import
|
||||
endpoint. Both flows are LAN-internal.
|
||||
- **Sound level meters (NL-43)** sit on the office LAN; SLMM reaches them
|
||||
via `network_mode: host`.
|
||||
|
||||
The **only** thing that needs to cross the office firewall is operator UI
|
||||
access from outside the office (laptops, phones, working from home). That
|
||||
makes the external networking layer trivial — see Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
On the Synology side:
|
||||
|
||||
- **DSM 7.2+** with **Container Manager** installed (Package Center).
|
||||
Older "Docker" package works too — same engine, different menu names.
|
||||
- **x86_64 model** (Plus / Value / XS series). ARM j-series will build but
|
||||
expect a slower first build.
|
||||
- **Static LAN IP** reserved for the NAS in the office router's DHCP table.
|
||||
Devices on the LAN must have a stable target.
|
||||
- **SSH enabled** — Control Panel → Terminal & SNMP → Enable SSH service.
|
||||
- **Shared folder** for the stack — e.g. `/volume1/docker/`.
|
||||
|
||||
On the home server side:
|
||||
|
||||
- Working terra-view / SFM / SLMM stack you want to migrate.
|
||||
- `rsync` available (it almost certainly is).
|
||||
|
||||
You will also need:
|
||||
|
||||
- An admin account on the Synology with sudo privileges.
|
||||
- Network access between the home server and the NAS during the migration
|
||||
window (or USB-drive shuttle if not).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Pre-stage on the NAS (no downtime)
|
||||
|
||||
Goal: get the NAS booting an empty stack so you can validate the build and
|
||||
networking *before* touching any production data.
|
||||
|
||||
### 1.1 Clone the repos
|
||||
|
||||
SSH to the NAS as admin:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /volume1/docker
|
||||
cd /volume1/docker
|
||||
sudo git clone <your-terra-view-remote> terra-view
|
||||
sudo git clone <your-slmm-remote> slmm
|
||||
sudo git clone <your-seismo-relay-remote> seismo-relay
|
||||
cd terra-view
|
||||
sudo git checkout main # or whichever branch you ship from
|
||||
```
|
||||
|
||||
### 1.2 Build images
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/terra-view
|
||||
sudo docker compose build
|
||||
```
|
||||
|
||||
First build takes 5–15 min depending on model.
|
||||
|
||||
### 1.3 Boot the empty stack
|
||||
|
||||
```bash
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
Hit `http://<nas-lan-ip>:1001` (dev profile) or `:8001` (prod profile) from
|
||||
another office machine. You should see an empty fleet roster. If that
|
||||
works, the NAS can run the stack — proven before any production data is
|
||||
at risk.
|
||||
|
||||
### 1.4 Stop the NAS stack again
|
||||
|
||||
```bash
|
||||
sudo docker compose stop
|
||||
```
|
||||
|
||||
We're ready for the data migration.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Data migration (~10 min window)
|
||||
|
||||
The terra-view stack is stateful in three places. All three must be moved
|
||||
together for consistency.
|
||||
|
||||
| Service | Data location (home server) |
|
||||
|------------|----------------------------------------------|
|
||||
| terra-view | `/home/serversdown/terra-view/data/` |
|
||||
| SLMM | `/home/serversdown/slmm/data/` |
|
||||
| SFM | `/home/serversdown/seismo-relay/data/` |
|
||||
|
||||
### 2.1 Stop writes on both sides
|
||||
|
||||
On the NAS:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/terra-view
|
||||
sudo docker compose stop
|
||||
```
|
||||
|
||||
On the home server:
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/terra-view
|
||||
docker compose stop terra-view slmm sfm
|
||||
```
|
||||
|
||||
### 2.2 rsync the data dirs
|
||||
|
||||
From the home server (or anywhere with SSH access to both):
|
||||
|
||||
```bash
|
||||
rsync -avh /home/serversdown/terra-view/data/ admin@<nas-lan-ip>:/volume1/docker/terra-view/data/
|
||||
rsync -avh /home/serversdown/slmm/data/ admin@<nas-lan-ip>:/volume1/docker/slmm/data/
|
||||
rsync -avh /home/serversdown/seismo-relay/data/ admin@<nas-lan-ip>:/volume1/docker/seismo-relay/data/
|
||||
```
|
||||
|
||||
### 2.3 Fix ownership on the NAS
|
||||
|
||||
Synology admin is usually UID `1026`, GID `100`. Inside containers running
|
||||
as root, this doesn't matter — but if you've configured `user:` in any
|
||||
compose file it will. Safe default:
|
||||
|
||||
```bash
|
||||
ssh admin@<nas-lan-ip> "sudo chown -R 1026:100 \
|
||||
/volume1/docker/terra-view/data \
|
||||
/volume1/docker/slmm/data \
|
||||
/volume1/docker/seismo-relay/data"
|
||||
```
|
||||
|
||||
### 2.4 Run any pending migrations
|
||||
|
||||
Some earlier feature work added migration scripts that need to run once
|
||||
per database. After the rsync, before starting the stack, check what's
|
||||
pending:
|
||||
|
||||
```bash
|
||||
ssh admin@<nas-lan-ip>
|
||||
cd /volume1/docker/terra-view
|
||||
ls backend/migrate_*.py
|
||||
```
|
||||
|
||||
Run each one inside the container (after starting it temporarily) or apply
|
||||
them on the host with the same Python environment. Idempotent migrations
|
||||
re-run safely.
|
||||
|
||||
### 2.5 Start the NAS stack
|
||||
|
||||
```bash
|
||||
ssh admin@<nas-lan-ip> \
|
||||
"cd /volume1/docker/terra-view && sudo docker compose up -d"
|
||||
```
|
||||
|
||||
### 2.6 Spot-check
|
||||
|
||||
- Dashboard loads with real units
|
||||
- `/sfm` page lists historical events
|
||||
- A photo loads on a unit detail page
|
||||
- SFM/HB badge mix on the active table matches what you saw on the home
|
||||
server
|
||||
|
||||
If anything's off, see [Rollback plan](#rollback-plan).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Repoint the watcher (download2-PC)
|
||||
|
||||
The download2-PC is the one client we have to reconfigure. It currently
|
||||
POSTs to the home server. Two endpoints to change:
|
||||
|
||||
1. **terra-view heartbeat URL** —
|
||||
`http://<old-home-ip>:8001/api/series3/heartbeat`
|
||||
→ `http://<new-nas-lan-ip>:8001/api/series3/heartbeat`
|
||||
|
||||
2. **SFM Blastware import URL** —
|
||||
`http://<old-home-ip>:8200/db/import/blastware_file`
|
||||
→ `http://<new-nas-lan-ip>:8200/db/import/blastware_file`
|
||||
|
||||
Or, if you want to keep SFM container-internal and not publish 8200 on
|
||||
the LAN at all, point it through terra-view's existing SFM proxy:
|
||||
→ `http://<new-nas-lan-ip>:8001/api/sfm/db/import/blastware_file`
|
||||
|
||||
Update the config, restart the watcher service, and confirm the next
|
||||
heartbeat lands in the NAS DB (check the Recent Call-Ins card on the
|
||||
dashboard).
|
||||
|
||||
> **Tip:** keep the home server running in parallel for 1–2 days. If you
|
||||
> forget to repoint something, it'll still flow into the old DB and you
|
||||
> can resync.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — External access for remote operators
|
||||
|
||||
Only the terra-view UI needs to be reachable from outside the office. Two
|
||||
clean options — pick one.
|
||||
|
||||
### Option A — Tailscale (recommended for small teams)
|
||||
|
||||
Zero port forwards, zero certs, zero public DNS, zero reverse proxy.
|
||||
|
||||
1. Install Tailscale from Synology Package Center, sign in.
|
||||
2. Install Tailscale on each operator's laptop/phone, sign in to the same
|
||||
tailnet.
|
||||
3. Operators access `http://<nas-tailscale-ip>:8001` from anywhere.
|
||||
|
||||
That's the whole setup. The office network has no external exposure at
|
||||
all.
|
||||
|
||||
### Option B — Reverse proxy with Let's Encrypt
|
||||
|
||||
If you want a `https://terraview.yourdomain.com` URL that any browser can
|
||||
reach:
|
||||
|
||||
#### B.1 Port forward on the office router
|
||||
|
||||
```
|
||||
WAN 443 → <nas-lan-ip>:443
|
||||
WAN 80 → <nas-lan-ip>:80 (only needed for Let's Encrypt HTTP-01;
|
||||
skip if you use DNS-01 challenge)
|
||||
```
|
||||
|
||||
Do **not** forward 1001, 8001, 8100, or 8200.
|
||||
|
||||
#### B.2 Public DNS
|
||||
|
||||
- Free: Synology DDNS (Control Panel → External Access → DDNS) — gives
|
||||
you `something.synology.me`.
|
||||
- Better: your own domain with an A record → office WAN IP, or a CNAME →
|
||||
Synology DDNS hostname (handles dynamic IPs automatically).
|
||||
|
||||
#### B.3 Let's Encrypt certificate
|
||||
|
||||
Control Panel → Security → Certificate → Add → "Get a certificate from
|
||||
Let's Encrypt." DSM handles renewal.
|
||||
|
||||
#### B.4 Synology reverse proxy
|
||||
|
||||
Control Panel → Login Portal → Advanced → Reverse Proxy → Create:
|
||||
|
||||
```
|
||||
Source: Hostname terraview.yourdomain.com
|
||||
Protocol HTTPS
|
||||
Port 443
|
||||
Destination: Hostname localhost
|
||||
Protocol HTTP
|
||||
Port 8001
|
||||
```
|
||||
|
||||
Under "Custom Header", add:
|
||||
|
||||
| Header | Value |
|
||||
|---------------------|------------------------------------|
|
||||
| `X-Forwarded-For` | `$proxy_add_x_forwarded_for` |
|
||||
| `X-Forwarded-Proto` | `$scheme` |
|
||||
| `Host` | `$host` |
|
||||
|
||||
Tick the WebSocket support checkbox.
|
||||
|
||||
#### B.5 DSM firewall
|
||||
|
||||
Control Panel → Security → Firewall → enable:
|
||||
|
||||
- 443/TCP from `Anywhere` — allow
|
||||
- 80/TCP from `Anywhere` — allow (cert renewal only)
|
||||
- Everything else from WAN — deny
|
||||
- All from LAN — allow
|
||||
|
||||
Optional: geo-block to your country if your operators are domestic only.
|
||||
Big reduction in scanning noise.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Decommission home server
|
||||
|
||||
After 1–2 weeks of stable NAS operation:
|
||||
|
||||
1. Take a final `docker compose down` on the home server.
|
||||
2. Archive `/home/serversdown/{terra-view,slmm,seismo-relay}/data/` to a
|
||||
backup volume.
|
||||
3. Free the home server hardware.
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist
|
||||
|
||||
After Phase 2 (data migration):
|
||||
|
||||
- [ ] `http://<nas-lan-ip>:8001/` loads dashboard with real units
|
||||
- [ ] Recent Alerts, Call-Ins (2 cols), Fleet Summary across the top
|
||||
- [ ] SFM/HB badge mix on the active table looks sane
|
||||
- [ ] `/sfm` page lists historical events (the same count as before)
|
||||
- [ ] A unit detail page loads with photos rendering
|
||||
- [ ] `/api/recent-event-callins` returns 200 with real data
|
||||
- [ ] `/api/status-snapshot` returns 200, `sfm_reachable: true`
|
||||
|
||||
After Phase 3 (watcher cutover):
|
||||
|
||||
- [ ] Next heartbeat from download2-PC lands in NAS DB
|
||||
- [ ] A new event arrives in `/sfm` page on the NAS within the next
|
||||
Blastware ACH cycle
|
||||
- [ ] No errors in `docker logs terra-view-terra-view-1`
|
||||
|
||||
After Phase 4 (external access):
|
||||
|
||||
- [ ] (Option A) Operator laptop on tailnet can reach
|
||||
`http://<nas-tailscale-ip>:8001`
|
||||
- [ ] (Option B) `https://terraview.yourdomain.com` resolves, cert is
|
||||
valid, dashboard loads
|
||||
- [ ] (Option B) Office DSM admin (5001) is **not** reachable from outside
|
||||
|
||||
---
|
||||
|
||||
## Rollback plan
|
||||
|
||||
The home server stays alive in parallel through Phases 2–3 as a safety
|
||||
net. If anything goes wrong on the NAS:
|
||||
|
||||
1. On the home server:
|
||||
```bash
|
||||
cd /home/serversdown/terra-view
|
||||
docker compose up -d
|
||||
```
|
||||
2. Point download2-PC back at the home server IP.
|
||||
3. NAS data isn't lost — it's just sitting idle. Investigate, fix, retry.
|
||||
|
||||
The "irreversible" point is when you decommission the home server in
|
||||
Phase 5. Until then, you can always fall back.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
1. **Synology UID/GID quirks.** Synology admin is usually `1026:100`.
|
||||
Containers running as root inside don't care, but if your compose
|
||||
files set `user:`, mismatched UIDs cause SQLite "readonly database"
|
||||
errors. Easiest fix: omit `user:` and let containers run as root.
|
||||
|
||||
2. **`network_mode: host` for SLMM.** Required for LAN-direct comms with
|
||||
sound level meters. On Synology this binds to the NAS's interface —
|
||||
confirm nothing else on the NAS uses ports 8100 or 21 (FTP).
|
||||
|
||||
3. **Auto-start on boot.** Container Manager → Project → Settings →
|
||||
enable "Auto-restart". Otherwise a DSM update or NAS reboot drops the
|
||||
stack.
|
||||
|
||||
4. **`restart: unless-stopped` in compose.** Verify every service has it.
|
||||
DSM occasionally restarts Docker during DSM updates — this flag
|
||||
ensures everything comes back.
|
||||
|
||||
5. **Hyper Backup.** Schedule a daily snapshot of
|
||||
`/volume1/docker/terra-view/data/` to a USB drive or off-site. SQLite
|
||||
+ small photo dir = trivially small backups. The DB-Management UI's
|
||||
built-in snapshots are an additional layer but not a replacement.
|
||||
|
||||
6. **NAT loopback (Option B only).** If your office router doesn't
|
||||
support hairpinning, machines INSIDE the office can't reach the NAS
|
||||
by its public hostname — they have to use the LAN IP. Most modern
|
||||
routers handle this; some ISP-provided ones don't. Test from a laptop
|
||||
on the office Wi-Fi.
|
||||
|
||||
7. **Let's Encrypt rate limits (Option B only).** 5 issuances per domain
|
||||
per week. Don't fat-finger DNS or you'll be locked out. Test with the
|
||||
staging endpoint first if unsure.
|
||||
|
||||
8. **`host.docker.internal` resolution.** terra-view's
|
||||
`SFM_BASE_URL=http://host.docker.internal:8200` relies on Docker's
|
||||
internal DNS. Works on DSM 7.2+ in bridge mode. If you see "name not
|
||||
resolved" errors, fall back to explicit container names with a custom
|
||||
network in compose.
|
||||
|
||||
9. **SFM stale rows.** The SFM SQLite has a few rows in `monitor_log`
|
||||
and `ach_sessions` from earlier Python-ACH experiments. Harmless
|
||||
to bring over — invisible to terra-view's UI under the
|
||||
watcher-forward pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Suggested timeline
|
||||
|
||||
For a low-risk migration:
|
||||
|
||||
- **Week 1**: Phase 1. Get the NAS booting an empty stack. No production
|
||||
touch.
|
||||
- **Week 2, day 1**: Phase 2. Migrate data. 10-min window. Keep home
|
||||
server alive in parallel.
|
||||
- **Week 2, day 1**: Phase 3. Repoint download2-PC. Watch heartbeats
|
||||
land on the NAS for the rest of the day.
|
||||
- **Week 3**: Phase 4. Add Tailscale or reverse-proxy access for remote
|
||||
operators.
|
||||
- **Week 4–5**: Monitor. Confirm everything's stable. Then Phase 5
|
||||
(decommission home server).
|
||||
|
||||
Splitting "make it work on LAN" from "expose it remotely" means you
|
||||
debug one thing at a time.
|
||||
@@ -8,3 +8,4 @@ aiofiles==23.2.1
|
||||
Pillow==10.1.0
|
||||
httpx==0.25.2
|
||||
openpyxl==3.1.2
|
||||
rapidfuzz==3.10.1
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
{% 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 & 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 failedCount = (d.failed || []).length;
|
||||
|
||||
// Three states:
|
||||
// total success — applied N, no failures → green toast, 4s
|
||||
// partial — applied N, M failures → red toast + modal listing reasons
|
||||
// total failure — applied 0, failures → red toast + modal
|
||||
if (failedCount === 0) {
|
||||
const sub = `${d.applied} applied · ${d.project_ids_created.length} new project(s) · ${d.location_ids_created.length} new location(s)`;
|
||||
_showToast(`${d.applied} cluster${d.applied === 1 ? '' : 's'} applied`, sub, 'success');
|
||||
_hideToast(4000);
|
||||
} else {
|
||||
const title = d.applied > 0
|
||||
? `${d.applied} applied, ${failedCount} failed`
|
||||
: `Apply failed — ${failedCount} cluster${failedCount === 1 ? '' : 's'} could not be applied`;
|
||||
_showToast(title, 'See the details panel.', 'error');
|
||||
_hideToast(6000);
|
||||
_showFailureDetails(d.failed);
|
||||
}
|
||||
await runScan(true); // refresh
|
||||
} catch (e) {
|
||||
_showToast('Apply failed', e.message, 'error');
|
||||
_hideToast(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Modal-ish panel that lists each failed cluster with its server-side
|
||||
// reason. Common failure modes seen in dev: missing DB tables after a
|
||||
// stale schema, blocking conflicts that slipped past the front-end guard,
|
||||
// rapidfuzz/SQLAlchemy edge cases. Operator can dismiss and either
|
||||
// retry the cluster, skip it, or fix the underlying issue.
|
||||
function _showFailureDetails(failed) {
|
||||
let panel = document.getElementById('apply-failure-panel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'apply-failure-panel';
|
||||
panel.className = 'fixed bottom-6 left-6 right-6 sm:right-auto sm:max-w-xl bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-red-200 dark:border-red-800 p-4 z-40';
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
const rows = failed.map(f => `
|
||||
<li class="flex items-start gap-2 text-sm border-l-2 border-red-300 dark:border-red-700 pl-3 py-1">
|
||||
<code class="font-mono text-xs text-gray-500 dark:text-gray-400">${(f.cluster_id || '').slice(0, 8)}…</code>
|
||||
<span class="text-gray-800 dark:text-gray-200 flex-1">${_esc(f.reason || '(no reason)')}</span>
|
||||
</li>
|
||||
`).join('');
|
||||
panel.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||
<svg class="w-5 h-5 inline text-red-500 -mt-0.5 mr-1" 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"/></svg>
|
||||
${failed.length} cluster${failed.length === 1 ? '' : 's'} failed to apply
|
||||
</h4>
|
||||
<button onclick="document.getElementById('apply-failure-panel').remove()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<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>
|
||||
<ul class="space-y-1 max-h-64 overflow-y-auto">${rows}</ul>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
Common causes: missing DB schema (run the migration sweep), blocking conflict
|
||||
with an existing UnitAssignment, or a UNIQUE constraint collision on the
|
||||
project name. Re-scan and the failed clusters reappear as pending — fix the
|
||||
underlying issue and retry.
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
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,267 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Project Tidy - 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">Project Tidy</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Project Tidy</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Find duplicate-looking projects via fuzzy name matching, then merge them with one click.
|
||||
Useful after the metadata-backfill parser creates near-duplicates from operator name variations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-5">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Similarity threshold</label>
|
||||
<select id="threshold" onchange="runScan()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="0.95">≥ 95% — near-identical only (typos)</option>
|
||||
<option value="0.90">≥ 90% — close variants</option>
|
||||
<option value="0.85" selected>≥ 85% — fuzzy match floor (recommended)</option>
|
||||
<option value="0.80">≥ 80% — aggressive (more false positives)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="runScan()"
|
||||
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
↻ Scan for duplicates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" class="space-y-3">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
Click "Scan for duplicates" to find pairs.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply progress toast -->
|
||||
<div id="tidy-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">Working…</p>
|
||||
<p id="toast-sub" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _pairs = [];
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _sourceBadge(source) {
|
||||
if (source === 'metadata_backfill' || source === 'metadata_backfill_swap') {
|
||||
return '<span class="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" title="Auto-created by the metadata-backfill parser">parser</span>';
|
||||
}
|
||||
return '<span class="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300" title="Manually created via the UI">manual</span>';
|
||||
}
|
||||
|
||||
function _showToast(message, sub, kind) {
|
||||
const toast = document.getElementById('tidy-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('tidy-toast').classList.add('hidden'), after || 3000);
|
||||
}
|
||||
|
||||
async function runScan() {
|
||||
const results = document.getElementById('results');
|
||||
results.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…</div>';
|
||||
const threshold = document.getElementById('threshold').value;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/admin/duplicate_pairs?threshold=${threshold}`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
_pairs = d.pairs || [];
|
||||
render();
|
||||
} catch (e) {
|
||||
results.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>`;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const results = document.getElementById('results');
|
||||
if (_pairs.length === 0) {
|
||||
results.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">✨ No duplicates above the threshold</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">Lower the threshold or call it good.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-3">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Found <strong>${_pairs.length}</strong> duplicate pair${_pairs.length === 1 ? '' : 's'}.
|
||||
Review the suggested merge direction (arrow points at the target project to keep),
|
||||
adjust if needed, then click <strong>Merge</strong>.
|
||||
</div>
|
||||
</div>`;
|
||||
results.innerHTML = summary + _pairs.map(_renderPair).join('');
|
||||
}
|
||||
|
||||
function _renderPair(pair, i) {
|
||||
const sourceTarget = pair.suggested_target_id === pair.a.id ? 'a' : 'b';
|
||||
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4" data-idx="${i}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">${(pair.score * 100).toFixed(0)}% match</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(pair.reason)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="confirmMerge(${i}, 'a_into_b')"
|
||||
class="px-3 py-1.5 text-xs rounded ${sourceTarget === 'b' ? 'bg-seismo-orange hover:bg-seismo-navy text-white font-medium' : 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}">
|
||||
Merge A → B
|
||||
</button>
|
||||
<button onclick="confirmMerge(${i}, 'b_into_a')"
|
||||
class="px-3 py-1.5 text-xs rounded ${sourceTarget === 'a' ? 'bg-seismo-orange hover:bg-seismo-navy text-white font-medium' : 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}">
|
||||
Merge B → A
|
||||
</button>
|
||||
<button onclick="dismissPair(${i})"
|
||||
title="Hide this pair (not actually a duplicate)"
|
||||
class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 rounded hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Not a dup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
${_renderProject(pair.a, 'A', sourceTarget === 'a')}
|
||||
${_renderProject(pair.b, 'B', sourceTarget === 'b')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderProject(p, label, isTarget) {
|
||||
const borderCls = isTarget ? 'border-seismo-orange ring-1 ring-seismo-orange/30' : 'border-gray-200 dark:border-gray-700';
|
||||
return `<a href="/projects/${_esc(p.id)}" target="_blank"
|
||||
class="block bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 border ${borderCls} hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Project ${label}${isTarget ? ' · suggested target' : ''}</div>
|
||||
${_sourceBadge(p.source)}
|
||||
</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white text-sm">${_esc(p.name)}</div>
|
||||
${p.project_number ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">#${_esc(p.project_number)}</div>` : ''}
|
||||
${p.client_name ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${_esc(p.client_name)}</div>` : ''}
|
||||
<div class="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400 mt-2">
|
||||
<span><strong>${p.location_count}</strong> location${p.location_count === 1 ? '' : 's'}</span>
|
||||
<span><strong>${p.assignment_count}</strong> assignment${p.assignment_count === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
async function confirmMerge(idx, direction) {
|
||||
const pair = _pairs[idx];
|
||||
if (!pair) return;
|
||||
let sourceId, targetId, sourceName, targetName;
|
||||
if (direction === 'a_into_b') {
|
||||
sourceId = pair.a.id; targetId = pair.b.id;
|
||||
sourceName = pair.a.name; targetName = pair.b.name;
|
||||
} else {
|
||||
sourceId = pair.b.id; targetId = pair.a.id;
|
||||
sourceName = pair.b.name; targetName = pair.a.name;
|
||||
}
|
||||
|
||||
// Pull preview to surface conflicts / consolidation count BEFORE merging.
|
||||
let preview;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${sourceId}/merge_preview?target_id=${targetId}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || ('HTTP ' + r.status));
|
||||
}
|
||||
preview = await r.json();
|
||||
} catch (e) {
|
||||
alert('Preview failed: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = [
|
||||
`${preview.total_assignments_moving} assignment(s)`,
|
||||
`${preview.total_sessions_moving} session(s)`,
|
||||
`${preview.total_data_files_moving} data file(s)`,
|
||||
].join(', ');
|
||||
let consolidation = '';
|
||||
const consolidates = preview.location_plans.filter(p => p.action === 'consolidate').length;
|
||||
if (consolidates > 0) {
|
||||
consolidation = `\n\n${consolidates} same-named location(s) will be consolidated.`;
|
||||
}
|
||||
const ok = confirm(
|
||||
`Merge "${sourceName}" into "${targetName}"?\n\n` +
|
||||
`Will move: ${summary}.${consolidation}\n\n` +
|
||||
`Source will be soft-deleted. This is reversible only via direct DB edit.`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
_showToast(`Merging "${sourceName}" → "${targetName}"…`);
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${sourceId}/merge_into?target_id=${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();
|
||||
_showToast(`Merged into "${targetName}"`,
|
||||
`${d.assignments_moved} assignment(s), ${d.locations_moved + d.locations_consolidated} location(s)`,
|
||||
'success');
|
||||
_hideToast(3500);
|
||||
// Re-scan: list updates without the merged pair.
|
||||
await runScan();
|
||||
} catch (e) {
|
||||
_showToast('Merge failed', e.message, 'error');
|
||||
_hideToast(5000);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissPair(idx) {
|
||||
// Just hide locally for now; doesn't persist across re-scans.
|
||||
// A persistent "ignore pair" feature would need a new table; defer.
|
||||
_pairs.splice(idx, 1);
|
||||
render();
|
||||
}
|
||||
|
||||
// Auto-scan on load with default threshold.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
runScan();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,264 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}SFM Admin - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/settings#developer" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Developer Tools</a>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">SFM Admin</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Diagnostics for the Seismograph Field Module (SFM) backend.</p>
|
||||
</div>
|
||||
<button onclick="loadSfmOverview()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
|
||||
<span id="refresh-label">↻ Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Health Banner -->
|
||||
<div id="health-banner" class="rounded-xl p-4 mb-6 bg-gray-100 dark:bg-slate-800 border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading SFM status…</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection Info -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-2">Connection</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">terra-view → SFM URL</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5">{{ sfm_base_url }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Last checked</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="checked-at">—</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Version</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="sfm-version">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Known Units</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white mt-1" id="stat-units">—</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white mt-1" id="stat-events">—</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Rows in SFM's deprecated monitor_log table (from paused Python-ACH experiment)">Stale: monitor_log</div>
|
||||
<div class="text-2xl font-bold text-gray-400 dark:text-gray-500 mt-1" id="stat-stale-monitor">—</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Rows in SFM's deprecated ach_sessions table (from paused Python-ACH experiment)">Stale: ach_sessions</div>
|
||||
<div class="text-2xl font-bold text-gray-400 dark:text-gray-500 mt-1" id="stat-stale-sessions">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Units Table -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Per-Unit Roll-up</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">All seismograph serials SFM has ever seen, with their last-event timestamp and total event count. Sourced from <code class="font-mono">GET /db/units</code>.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<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">Serial</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Last Seen</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase text-right">Events</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase text-right">Monitor (stale)</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase text-right">Sessions (stale)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="units-tbody" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Events with Latency -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Recent Events — Forwarding Latency</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">The last 25 events SFM ingested, with the gap between the event's recorded timestamp and when SFM received the forward. Large latencies indicate the watcher is forwarding stale files (e.g. after a network outage).</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<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">Recorded</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Serial</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Forwarded</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Latency</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">File</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-tbody" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw API tester -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Send a GET request to any SFM endpoint via the terra-view <code class="font-mono">/api/sfm/*</code> proxy. Path is relative to SFM root (no leading slash).</p>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<span class="px-3 py-2 text-sm bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 font-mono rounded-l-lg">/api/sfm/</span>
|
||||
<input id="raw-path" type="text" placeholder="db/units" value="db/units"
|
||||
class="flex-1 px-3 py-2 text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded font-mono"
|
||||
onkeydown="if(event.key==='Enter') sendRaw();">
|
||||
<button onclick="sendRaw()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg">
|
||||
GET
|
||||
</button>
|
||||
</div>
|
||||
<pre id="raw-response" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-3 rounded-lg max-h-96 overflow-auto whitespace-pre hidden"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function _fmtAge(seconds) {
|
||||
if (seconds == null) return '—';
|
||||
const abs = Math.abs(seconds);
|
||||
if (abs < 60) return seconds.toFixed(0) + 's';
|
||||
if (abs < 3600) return (seconds / 60).toFixed(1) + 'm';
|
||||
if (abs < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
||||
return (seconds / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
function _latencyClass(seconds) {
|
||||
if (seconds == null) return 'text-gray-400';
|
||||
if (seconds < 600) return 'text-green-600 dark:text-green-400'; // <10 min
|
||||
if (seconds < 3600) return 'text-amber-600 dark:text-amber-400'; // <1 hr
|
||||
return 'text-red-600 dark:text-red-400 font-semibold'; // 1hr+
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function loadSfmOverview() {
|
||||
const lbl = document.getElementById('refresh-label');
|
||||
lbl.textContent = '↻ Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/admin/sfm/overview');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
renderOverview(d);
|
||||
} catch (e) {
|
||||
document.getElementById('health-banner').innerHTML = `
|
||||
<div class="text-red-600 dark:text-red-400 text-sm font-medium">
|
||||
Failed to load SFM overview: ${_esc(e.message)}
|
||||
</div>`;
|
||||
} finally {
|
||||
lbl.textContent = '↻ Refresh';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverview(d) {
|
||||
// Banner
|
||||
const banner = document.getElementById('health-banner');
|
||||
if (d.reachable) {
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-green-800 dark:text-green-300">SFM reachable</div>
|
||||
<div class="text-xs text-green-700 dark:text-green-400">${_esc(d.health?.service || 'sfm')} v${_esc(d.health?.version || '?')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800';
|
||||
const errs = Object.entries(d.errors || {}).map(([k, v]) => `${k}: ${v}`).join('; ');
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-red-800 dark:text-red-300">SFM unreachable</div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400">${_esc(errs || 'no details')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Header info
|
||||
document.getElementById('checked-at').textContent = d.checked_at ? d.checked_at.slice(0, 19).replace('T', ' ') : '—';
|
||||
document.getElementById('sfm-version').textContent = d.health?.version || '—';
|
||||
|
||||
// Stats
|
||||
const t = d.totals || {};
|
||||
document.getElementById('stat-units').textContent = (t.units ?? 0).toLocaleString();
|
||||
document.getElementById('stat-events').textContent = (t.events_total ?? 0).toLocaleString();
|
||||
document.getElementById('stat-stale-monitor').textContent = t.stale_monitor_log != null ? t.stale_monitor_log.toLocaleString() : '—';
|
||||
document.getElementById('stat-stale-sessions').textContent = t.stale_sessions != null ? t.stale_sessions.toLocaleString() : '—';
|
||||
|
||||
// Units table
|
||||
const unitsBody = document.getElementById('units-tbody');
|
||||
if (!d.units || d.units.length === 0) {
|
||||
unitsBody.innerHTML = `<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">No units in SFM yet.</td></tr>`;
|
||||
} else {
|
||||
const sorted = [...d.units].sort((a, b) => (b.last_seen || '').localeCompare(a.last_seen || ''));
|
||||
unitsBody.innerHTML = sorted.map(u => {
|
||||
const ls = u.last_seen ? u.last_seen.replace('T', ' ').slice(0, 19) : '—';
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<td class="px-3 py-2 font-mono text-seismo-orange"><a href="/unit/${encodeURIComponent(u.serial)}" class="hover:underline">${_esc(u.serial)}</a></td>
|
||||
<td class="px-3 py-2 text-gray-900 dark:text-gray-200">${_esc(ls)}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-900 dark:text-gray-200">${(u.total_events || 0).toLocaleString()}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-500">${(u.total_monitor_entries || 0).toLocaleString()}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-500">${(u.total_sessions || 0).toLocaleString()}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Events table
|
||||
const evBody = document.getElementById('events-tbody');
|
||||
if (!d.events || d.events.length === 0) {
|
||||
evBody.innerHTML = `<tr><td colspan="5" class="px-3 py-6 text-center text-gray-500">No events.</td></tr>`;
|
||||
} else {
|
||||
evBody.innerHTML = d.events.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const ca = ev.created_at ? ev.created_at.replace('T', ' ').slice(0, 19) : '—';
|
||||
const lat = ev.forwarding_latency_seconds;
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<td class="px-3 py-2 text-gray-900 dark:text-gray-200 whitespace-nowrap">${_esc(ts)}</td>
|
||||
<td class="px-3 py-2 font-mono text-seismo-orange">${_esc(ev.serial)}</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">${_esc(ca)}</td>
|
||||
<td class="px-3 py-2 font-mono ${_latencyClass(lat)}">${_fmtAge(lat)}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">${_esc(ev.blastware_filename || '—')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRaw() {
|
||||
const path = document.getElementById('raw-path').value.trim().replace(/^\//, '');
|
||||
if (!path) return;
|
||||
const pre = document.getElementById('raw-response');
|
||||
pre.classList.remove('hidden');
|
||||
pre.textContent = 'Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/sfm/' + path);
|
||||
const text = await r.text();
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
pre.textContent = `HTTP ${r.status}\n\n${JSON.stringify(j, null, 2)}`;
|
||||
} catch {
|
||||
pre.textContent = `HTTP ${r.status}\n\n${text.slice(0, 8000)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
pre.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
loadSfmOverview();
|
||||
setInterval(loadSfmOverview, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}SLMM Admin - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/settings#developer" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Developer Tools</a>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">SLMM Admin</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Diagnostics for the Sound Level Meter Manager (SLMM) backend.</p>
|
||||
</div>
|
||||
<button onclick="loadSlmmOverview()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
|
||||
<span id="refresh-label">↻ Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Health Banner -->
|
||||
<div id="health-banner" class="rounded-xl p-4 mb-6 bg-gray-100 dark:bg-slate-800 border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading SLMM status…</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection Info -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-2">Connection</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">terra-view → SLMM URL</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5">{{ slmm_base_url }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Last checked</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="checked-at">—</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Version</span>
|
||||
<div class="font-mono text-gray-900 dark:text-white mt-0.5" id="slmm-version">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
For per-device SLM control, see the <a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-burgundy underline">Sound Level Meters dashboard</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw API tester -->
|
||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Send a GET request to any SLMM endpoint via the terra-view <code class="font-mono">/api/slmm/*</code> proxy.</p>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<span class="px-3 py-2 text-sm bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 font-mono rounded-l-lg">/api/slmm/</span>
|
||||
<input id="raw-path" type="text" placeholder="health" value="health"
|
||||
class="flex-1 px-3 py-2 text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded font-mono"
|
||||
onkeydown="if(event.key==='Enter') sendRaw();">
|
||||
<button onclick="sendRaw()"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg">
|
||||
GET
|
||||
</button>
|
||||
</div>
|
||||
<pre id="raw-response" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-3 rounded-lg max-h-96 overflow-auto whitespace-pre hidden"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function loadSlmmOverview() {
|
||||
const lbl = document.getElementById('refresh-label');
|
||||
lbl.textContent = '↻ Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/admin/slmm/overview');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
|
||||
document.getElementById('checked-at').textContent = d.checked_at ? d.checked_at.slice(0, 19).replace('T', ' ') : '—';
|
||||
document.getElementById('slmm-version').textContent = d.health?.version || '—';
|
||||
|
||||
const banner = document.getElementById('health-banner');
|
||||
if (d.reachable) {
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-green-800 dark:text-green-300">SLMM reachable</div>
|
||||
<div class="text-xs text-green-700 dark:text-green-400">${_esc(d.health?.service || 'slmm')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
const errs = Object.entries(d.errors || {}).map(([k, v]) => `${k}: ${v}`).join('; ');
|
||||
banner.className = 'rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500"></span>
|
||||
<div>
|
||||
<div class="font-semibold text-red-800 dark:text-red-300">SLMM unreachable</div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400">${_esc(errs || 'no details')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('health-banner').innerHTML = `
|
||||
<div class="text-red-600 dark:text-red-400 text-sm font-medium">
|
||||
Failed to load SLMM overview: ${_esc(e.message)}
|
||||
</div>`;
|
||||
} finally {
|
||||
lbl.textContent = '↻ Refresh';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRaw() {
|
||||
const path = document.getElementById('raw-path').value.trim().replace(/^\//, '');
|
||||
if (!path) return;
|
||||
const pre = document.getElementById('raw-response');
|
||||
pre.classList.remove('hidden');
|
||||
pre.textContent = 'Loading…';
|
||||
try {
|
||||
const r = await fetch('/api/slmm/' + path);
|
||||
const text = await r.text();
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
pre.textContent = `HTTP ${r.status}\n\n${JSON.stringify(j, null, 2)}`;
|
||||
} catch {
|
||||
pre.textContent = `HTTP ${r.status}\n\n${text.slice(0, 8000)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
pre.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
loadSlmmOverview();
|
||||
setInterval(loadSlmmOverview, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
+40
-35
@@ -109,47 +109,24 @@
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/roster' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
{# Devices — single sidebar entry covering all device-type
|
||||
pages. Lands on /roster (the unified all-devices view);
|
||||
the tab strip on each underlying page lets the operator
|
||||
drill into seismograph / SLM / modem specifics.
|
||||
Active when on any /seismographs, /sound-level-meters,
|
||||
/modems, /roster, /pair-devices, /unit/* page. #}
|
||||
{% set _is_devices = (
|
||||
request.url.path in ('/seismographs', '/sound-level-meters', '/modems', '/roster', '/pair-devices')
|
||||
or request.url.path.startswith('/unit/')
|
||||
or request.url.path.startswith('/slm/')
|
||||
) %}
|
||||
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_devices %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
Devices
|
||||
</a>
|
||||
|
||||
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
<a href="/sfm" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sfm' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" 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>
|
||||
SFM Events
|
||||
</a>
|
||||
|
||||
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
|
||||
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Modems
|
||||
</a>
|
||||
|
||||
<a href="/pair-devices" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/pair-devices' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
Pair Devices
|
||||
</a>
|
||||
|
||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
@@ -157,6 +134,34 @@
|
||||
Projects
|
||||
</a>
|
||||
|
||||
{# Events — fleet-wide event database (SFM). Cross-project
|
||||
sortable/filterable event list. Day-to-day event browsing
|
||||
for a specific location or unit lives on those detail
|
||||
pages; this is the firehose for cross-cutting queries. #}
|
||||
<a href="/sfm" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sfm' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" 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>
|
||||
Events
|
||||
</a>
|
||||
|
||||
{# Tools — operator workflow hub. Active when on /tools
|
||||
itself or any of the workflow pages it links into
|
||||
(project tidy, metadata backfill, pair devices). #}
|
||||
{% set _is_tools = (
|
||||
request.url.path == '/tools'
|
||||
or request.url.path == '/pair-devices'
|
||||
or request.url.path == '/settings/developer/project-tidy'
|
||||
or request.url.path == '/settings/developer/metadata-backfill'
|
||||
) %}
|
||||
<a href="/tools" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_tools %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
Tools
|
||||
</a>
|
||||
|
||||
<a href="/fleet-calendar" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/fleet-calendar') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
|
||||
+209
-137
@@ -29,7 +29,55 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
|
||||
<!-- Fleet Summary Card -->
|
||||
<!-- Recent Alerts Card (col 1) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<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.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Call-Ins Card (cols 2-3, double-wide) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 md:col-span-2 lg:col-span-2" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline">from SFM event forwards</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<a href="/sfm" class="block mt-3 text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
View all events →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fleet Summary Card (col 4) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||
@@ -121,74 +169,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Alerts Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<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.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Called In Units Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
Show all recent call-ins
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Scheduled Actions Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="todays-actions-content"
|
||||
hx-get="/dashboard/todays-actions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Filters -->
|
||||
@@ -269,6 +249,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Schedule — horizontal collapsible card.
|
||||
Default collapsed; auto-expands when an upcoming action is detected
|
||||
(pending + scheduled within the next 4h). JS reads
|
||||
data-has-upcoming on the inner partial after htmx swap. -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-8" id="todays-actions-card">
|
||||
<div class="flex items-center justify-between cursor-pointer" onclick="toggleTodaysSchedule()">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||
<span id="todays-actions-badge"
|
||||
class="hidden text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
</span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform collapsed" id="todays-actions-chevron"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content collapsed mt-4" id="todays-actions-content"
|
||||
hx-get="/dashboard/todays-actions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-swap="onTodaysActionsSwap(this)">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Photos Section -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||
@@ -364,6 +374,17 @@
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Today's Schedule — horizontal collapsible at all breakpoints. */
|
||||
#todays-actions-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
#todays-actions-chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
#todays-actions-chevron {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -654,7 +675,8 @@ function toggleCard(cardName) {
|
||||
// Restore card states from localStorage on page load
|
||||
function restoreCardStates() {
|
||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
|
||||
// Note: todays-actions has its own collapse handling (see toggleTodaysSchedule / onTodaysActionsSwap)
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||
|
||||
cardNames.forEach(cardName => {
|
||||
const content = document.getElementById(`${cardName}-content`);
|
||||
@@ -839,89 +861,139 @@ async function loadRecentPhotos() {
|
||||
loadRecentPhotos();
|
||||
setInterval(loadRecentPhotos, 30000);
|
||||
|
||||
// Load and display recent call-ins
|
||||
let showingAllCallins = false;
|
||||
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||
|
||||
// Load and display recent call-ins.
|
||||
// Source: SFM events (forwarded by series3-watcher from Blastware ACH).
|
||||
// Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
|
||||
// is being phased out but kept as a backup.
|
||||
async function loadRecentCallins() {
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
try {
|
||||
const response = await fetch('/api/recent-callins?hours=6');
|
||||
const response = await fetch('/api/recent-event-callins?limit=10');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load recent call-ins');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
|
||||
if (data.call_ins && data.call_ins.length > 0) {
|
||||
// Determine how many to show
|
||||
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||
|
||||
// Build HTML for call-ins list
|
||||
let html = '';
|
||||
callinsToDisplay.forEach(callin => {
|
||||
// Status color
|
||||
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||
|
||||
// Build location/note line
|
||||
let subtitle = '';
|
||||
if (callin.location) {
|
||||
subtitle = callin.location;
|
||||
} else if (callin.note) {
|
||||
subtitle = callin.note;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||
<div>
|
||||
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||
${callin.unit_id}
|
||||
</a>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
callinsList.innerHTML = html;
|
||||
|
||||
// Show/hide the "Show all" button
|
||||
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||
showAllButton.classList.remove('hidden');
|
||||
showAllButton.textContent = showingAllCallins
|
||||
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||
: `Show all (${data.call_ins.length})`;
|
||||
} else {
|
||||
showAllButton.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||
showAllButton.classList.add('hidden');
|
||||
if (!data.call_ins || data.call_ins.length === 0) {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Two-column dense grid on lg+, single column below.
|
||||
let html = '<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-4 gap-y-1">';
|
||||
data.call_ins.forEach(c => {
|
||||
const isFalse = c.false_trigger;
|
||||
const pvs = c.peak_vector_sum;
|
||||
const pvsStr = (pvs !== null && pvs !== undefined)
|
||||
? Number(pvs).toFixed(3) + ' in/s'
|
||||
: '—';
|
||||
|
||||
// Subtitle: prefer sensor_location, fallback to project.
|
||||
const subtitle = c.sensor_location || c.project || '';
|
||||
|
||||
// Status dot: amber for false trigger, green for real event,
|
||||
// gray if unit not in roster.
|
||||
const dotClass = !c.in_roster
|
||||
? 'bg-gray-400'
|
||||
: (isFalse ? 'bg-amber-400' : 'bg-green-500');
|
||||
|
||||
// Format event timestamp short (e.g. "05-13 05:00").
|
||||
let tsShort = '';
|
||||
if (c.event_timestamp) {
|
||||
const ts = c.event_timestamp.replace('T', ' ');
|
||||
// "2026-05-13 05:00:13" → "05-13 05:00"
|
||||
tsShort = ts.length >= 16 ? ts.slice(5, 16) : ts;
|
||||
}
|
||||
|
||||
const unitLink = c.in_roster
|
||||
? `<a href="/unit/${c.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">${c.unit_id}</a>`
|
||||
: `<span class="font-medium text-gray-500 dark:text-gray-400" title="Not in roster">${c.unit_id}</span>`;
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
${unitLink}
|
||||
${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${pvsStr}</span>
|
||||
</div>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${subtitle.replace(/"/g, '"')}">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-2 flex-shrink-0">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 block">${c.time_ago}</span>
|
||||
${tsShort ? `<span class="text-[10px] text-gray-400 dark:text-gray-500 block font-mono">${tsShort}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
callinsList.innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('Error loading recent call-ins:', error);
|
||||
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
callinsList.innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle show all/show fewer
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
showAllButton.addEventListener('click', function() {
|
||||
showingAllCallins = !showingAllCallins;
|
||||
loadRecentCallins();
|
||||
});
|
||||
});
|
||||
|
||||
// Load recent call-ins on page load and refresh every 30 seconds
|
||||
// Load recent call-ins on page load and refresh every 30 seconds.
|
||||
loadRecentCallins();
|
||||
setInterval(loadRecentCallins, 30000);
|
||||
|
||||
// ===== Today's Schedule horizontal card =====
|
||||
function toggleTodaysSchedule() {
|
||||
const content = document.getElementById('todays-actions-content');
|
||||
const chevron = document.getElementById('todays-actions-chevron');
|
||||
if (!content || !chevron) return;
|
||||
const isCollapsed = content.classList.toggle('collapsed');
|
||||
chevron.classList.toggle('collapsed', isCollapsed);
|
||||
// Remember the user's explicit choice so we don't fight them on the next
|
||||
// 30s htmx refresh.
|
||||
localStorage.setItem('todaysScheduleUserToggled', '1');
|
||||
localStorage.setItem('todaysScheduleCollapsed', isCollapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
function onTodaysActionsSwap(el) {
|
||||
// Read pending/total counts from the rendered partial to drive
|
||||
// auto-expand + the header badge.
|
||||
const badge = document.getElementById('todays-actions-badge');
|
||||
const content = document.getElementById('todays-actions-content');
|
||||
const chevron = document.getElementById('todays-actions-chevron');
|
||||
if (!content || !chevron) return;
|
||||
|
||||
// Count yellow status indicators in the rendered partial as a proxy for
|
||||
// "pending action present today".
|
||||
const pendingDots = el.querySelectorAll('.bg-yellow-400').length;
|
||||
const pendingTimes = el.querySelectorAll('.text-yellow-600').length;
|
||||
const hasPending = pendingDots > 0 || pendingTimes > 0;
|
||||
|
||||
if (badge) {
|
||||
if (hasPending) {
|
||||
const n = Math.max(pendingDots, pendingTimes);
|
||||
badge.textContent = `${n} pending today`;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expand only if the user hasn't manually toggled this session AND
|
||||
// there's something pending. Once the user collapses/expands manually,
|
||||
// their preference sticks.
|
||||
const userToggled = localStorage.getItem('todaysScheduleUserToggled') === '1';
|
||||
if (!userToggled && hasPending) {
|
||||
content.classList.remove('collapsed');
|
||||
chevron.classList.remove('collapsed');
|
||||
} else if (!userToggled && !hasPending) {
|
||||
content.classList.add('collapsed');
|
||||
chevron.classList.add('collapsed');
|
||||
} else if (userToggled) {
|
||||
const stored = localStorage.getItem('todaysScheduleCollapsed') === '1';
|
||||
content.classList.toggle('collapsed', stored);
|
||||
chevron.classList.toggle('collapsed', stored);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Field Modems - Terra-View{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -36,7 +36,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Age -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
||||
{% if unit.last_seen_source == 'sfm' %}
|
||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-seismo-orange/10 text-seismo-orange font-semibold"
|
||||
title="Status sourced from SFM event forwards (primary)">SFM</span>
|
||||
{% elif unit.last_seen_source == 'heartbeat' %}
|
||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300"
|
||||
title="Status sourced from watcher heartbeat (backup)">HB</span>
|
||||
{% endif %}
|
||||
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
|
||||
{{ unit.age }}
|
||||
</span>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,54 @@
|
||||
{# Fleet tab strip.
|
||||
|
||||
Shared header for every page under the "Fleet" sidebar section. Each
|
||||
underlying page (/roster, /seismographs, /sound-level-meters, /modems)
|
||||
keeps its own custom layout — this partial just provides the tab
|
||||
navigation across the top so they feel like one logical area.
|
||||
|
||||
The active tab is detected from request.url.path so deep links work.
|
||||
|
||||
Usage at top of any Fleet-section template:
|
||||
{% include 'partials/fleet_tab_strip.html' %}
|
||||
#}
|
||||
{% set _path = request.url.path %}
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-0">
|
||||
<nav class="flex gap-1">
|
||||
<a href="/roster"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/roster' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
All Devices
|
||||
</a>
|
||||
<a href="/seismographs"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/seismographs' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
<a href="/sound-level-meters"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/sound-level-meters' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
<a href="/modems"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/modems' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
|
||||
</svg>
|
||||
Modems
|
||||
</a>
|
||||
</nav>
|
||||
<a href="/pair-devices"
|
||||
class="mb-1 inline-flex items-center gap-1.5 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 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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
Pair Devices
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,10 +75,266 @@
|
||||
Generate Combined Report
|
||||
</a>
|
||||
{% 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>
|
||||
|
||||
<!-- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 -->
|
||||
<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">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "{:,}".format(summary.total_events) }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
|
||||
{% if summary.peak_pvs is not none %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.4f"|format(summary.peak_pvs) }} <span class="text-sm font-normal">in/s</span></span>
|
||||
<a href="/projects/{{ summary.project_id }}/nrl/{{ summary.peak_pvs_location_id }}"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Devices - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||
|
||||
@@ -560,6 +560,38 @@
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- SFM Admin -->
|
||||
<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">SFM Admin</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Diagnose the SFM backend — health, per-unit event counts, forwarding latency, stale tables, raw API probe.
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/sfm"
|
||||
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>
|
||||
|
||||
<!-- SLMM Admin -->
|
||||
<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">SLMM Admin</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Diagnose the SLMM backend — health check + raw API probe. For per-device control use the SLM dashboard.
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/slmm"
|
||||
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>
|
||||
|
||||
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
||||
operator workflows, not admin/dev surfaces). Find them
|
||||
at /tools. #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+67
-79
@@ -5,8 +5,8 @@
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">SFM Event Data</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Blastware ACH events forwarded by series3-watcher</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Events</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet-wide event database. Filter by serial, date, false-trigger, or browse the units roster.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="sfm-status-badge" class="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
@@ -115,21 +115,9 @@
|
||||
|
||||
</div><!-- end tab container -->
|
||||
|
||||
<!-- Event detail modal -->
|
||||
<div id="event-modal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="absolute inset-0 bg-black/60" onclick="closeEventModal()"></div>
|
||||
<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>
|
||||
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
|
||||
<style>
|
||||
.sfm-tab {
|
||||
@@ -232,6 +220,12 @@ async function loadStats() {
|
||||
}
|
||||
|
||||
// ── Events tab ───────────────────────────────────────────────────────────────
|
||||
// Module-level cache so sort can re-render without re-fetching.
|
||||
let _eventsCache = [];
|
||||
let _eventsTotal = 0;
|
||||
let _eventsSortKey = 'timestamp';
|
||||
let _eventsSortDir = 'desc'; // 'asc' | 'desc'
|
||||
|
||||
async function loadEvents() {
|
||||
const container = document.getElementById('events-container');
|
||||
container.innerHTML = '<div class="text-center py-8 text-gray-500"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
|
||||
@@ -253,19 +247,61 @@ async function loadEvents() {
|
||||
const r = await fetch('/api/sfm/db/events?' + params.toString());
|
||||
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
||||
const d = await r.json();
|
||||
renderEventsTable(d.events, d.count, container);
|
||||
_eventsCache = d.events || [];
|
||||
_eventsTotal = d.count || 0;
|
||||
renderEventsTable(_eventsCache, _eventsTotal, container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function sortEvents(key) {
|
||||
// Toggle direction if same column clicked; otherwise default to desc.
|
||||
if (_eventsSortKey === key) {
|
||||
_eventsSortDir = _eventsSortDir === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
_eventsSortKey = key;
|
||||
_eventsSortDir = 'desc';
|
||||
}
|
||||
renderEventsTable(_eventsCache, _eventsTotal, document.getElementById('events-container'));
|
||||
}
|
||||
|
||||
function _applySort(events) {
|
||||
const key = _eventsSortKey;
|
||||
const dir = _eventsSortDir === 'asc' ? 1 : -1;
|
||||
return [...events].sort((a, b) => {
|
||||
let av = a[key], bv = b[key];
|
||||
// Nulls always sort last regardless of dir.
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||
return String(av).localeCompare(String(bv)) * dir;
|
||||
});
|
||||
}
|
||||
|
||||
function _sortIndicator(key) {
|
||||
if (_eventsSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
|
||||
return _eventsSortDir === 'desc'
|
||||
? '<span class="text-seismo-orange ml-1">↓</span>'
|
||||
: '<span class="text-seismo-orange ml-1">↑</span>';
|
||||
}
|
||||
|
||||
function _sortableTh(label, key) {
|
||||
return `<th onclick="sortEvents('${key}')"
|
||||
class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||
${label}${_sortIndicator(key)}
|
||||
</th>`;
|
||||
}
|
||||
|
||||
function renderEventsTable(events, total, container) {
|
||||
if (!events || events.length === 0) {
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><p class="text-sm">No events found matching the current filters.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = events.map(ev => {
|
||||
const sorted = _applySort(events);
|
||||
const rows = sorted.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const tran = fmtPPV(ev.tran_ppv);
|
||||
const vert = fmtPPV(ev.vert_ppv);
|
||||
@@ -275,7 +311,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 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 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>
|
||||
@@ -300,15 +336,15 @@ function renderEventsTable(events, total, container) {
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Project</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||
${_sortableTh('Timestamp', 'timestamp')}
|
||||
${_sortableTh('Serial', 'serial')}
|
||||
${_sortableTh('Project', 'project')}
|
||||
${_sortableTh('Tran', 'tran_ppv')}
|
||||
${_sortableTh('Vert', 'vert_ppv')}
|
||||
${_sortableTh('Long', 'long_ppv')}
|
||||
${_sortableTh('PVS', 'peak_vector_sum')}
|
||||
${_sortableTh('Mic', 'mic_ppv')}
|
||||
${_sortableTh('Flags', 'false_trigger')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
@@ -354,56 +390,8 @@ async function toggleFalseTrigger(id, newValue, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event detail modal ───────────────────────────────────────────────────────
|
||||
function showEventDetail(jsonStr) {
|
||||
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');
|
||||
}
|
||||
// Event detail modal now lives in /static/event-modal.js (shared component).
|
||||
// `showEventDetail(eventId)` is exposed globally; row onclick handlers call it.
|
||||
|
||||
// ── Units tab ────────────────────────────────────────────────────────────────
|
||||
async function loadUnits() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Tools - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Tools</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Active operator workflows. Pair devices, clean up duplicates, generate reports.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
<!-- Pair Devices -->
|
||||
<a href="/pair-devices"
|
||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-seismo-orange flex items-center justify-center shrink-0">
|
||||
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Pair Devices</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Bidirectionally link seismographs ↔ modems (or SLMs ↔ modems) so they ship out together as a deployed pair.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Project Tidy -->
|
||||
<a href="/settings/developer/project-tidy"
|
||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center shrink-0">
|
||||
<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="M19 11H5m14-4H5m14 8H5m14 4H5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Project Tidy</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Find duplicate-looking projects via fuzzy name match (typos, abbreviations) and bulk-merge them. Useful after a metadata backfill run.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Metadata Backfill -->
|
||||
<a href="/settings/developer/metadata-backfill"
|
||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 flex items-center justify-center shrink-0">
|
||||
<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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Backfill from event metadata</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Auto-create projects, locations, and unit assignments from the operator-typed metadata baked into SFM events. Skip the manual entry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Reports (per-project) -->
|
||||
<a href="/projects"
|
||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0">
|
||||
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Reports</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Excel report generation lives on each project's detail page. Open a project and use <em>Generate Combined Report</em> (for multi-location sound studies) or single-location export.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Swap Detection (Phase 5c — coming soon) -->
|
||||
<div class="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-400 flex items-center justify-center shrink-0">
|
||||
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-gray-500 dark:text-gray-400">Swap Detection</h3>
|
||||
<span class="px-1.5 py-0.5 rounded text-xs bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400">soon</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">
|
||||
Daily background job that auto-detects unit swaps in the field (BE12345 → BE67890 at the same project + location) from operator-typed metadata. Coming in Phase 5c.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
+79
-14
@@ -313,7 +313,7 @@
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">outside any assignment window</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
|
||||
<span id="ue-stat-peak" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span id="ue-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
||||
</div>
|
||||
@@ -2142,6 +2142,15 @@ function clearUnitEventFilters() {
|
||||
loadUnitEvents();
|
||||
}
|
||||
|
||||
// Module-level state for the unit-events table sort. Cache lets us re-sort
|
||||
// without a refetch when the user clicks a column header.
|
||||
let _ueEventsCache = [];
|
||||
let _ueEventsTotal = 0;
|
||||
let _ueEventsBucket = 'all';
|
||||
let _ueAssignmentsTotal = 0;
|
||||
let _ueSortKey = 'timestamp';
|
||||
let _ueSortDir = 'desc';
|
||||
|
||||
async function loadUnitEvents() {
|
||||
if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
|
||||
const container = document.getElementById('ue-events-container');
|
||||
@@ -2166,13 +2175,62 @@ async function loadUnitEvents() {
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
const d = await r.json();
|
||||
_ueEventsCache = d.events || [];
|
||||
_ueEventsTotal = d.count || 0;
|
||||
_ueEventsBucket = bucket;
|
||||
_ueAssignmentsTotal = d.assignments_total || 0;
|
||||
renderUnitEventStats(d.stats);
|
||||
renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total);
|
||||
renderUnitEventTable(_ueEventsCache, _ueEventsTotal, container, bucket, _ueAssignmentsTotal);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function sortUnitEvents(key) {
|
||||
if (_ueSortKey === key) {
|
||||
_ueSortDir = _ueSortDir === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
_ueSortKey = key;
|
||||
_ueSortDir = 'desc';
|
||||
}
|
||||
renderUnitEventTable(_ueEventsCache, _ueEventsTotal,
|
||||
document.getElementById('ue-events-container'), _ueEventsBucket, _ueAssignmentsTotal);
|
||||
}
|
||||
|
||||
function _ueApplySort(events) {
|
||||
const key = _ueSortKey;
|
||||
const dir = _ueSortDir === 'asc' ? 1 : -1;
|
||||
return [...events].sort((a, b) => {
|
||||
let av, bv;
|
||||
if (key === 'attribution') {
|
||||
// Sort by location name so attributed rows group together.
|
||||
av = a.attribution ? (a.attribution.location_name || '') : '';
|
||||
bv = b.attribution ? (b.attribution.location_name || '') : '';
|
||||
} else {
|
||||
av = a[key]; bv = b[key];
|
||||
}
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||
return String(av).localeCompare(String(bv)) * dir;
|
||||
});
|
||||
}
|
||||
|
||||
function _ueSortIndicator(key) {
|
||||
if (_ueSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
|
||||
return _ueSortDir === 'desc'
|
||||
? '<span class="text-seismo-orange ml-1">↓</span>'
|
||||
: '<span class="text-seismo-orange ml-1">↑</span>';
|
||||
}
|
||||
|
||||
function _ueSortableTh(label, key) {
|
||||
return `<th onclick="sortUnitEvents('${key}')"
|
||||
class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||
${label}${_ueSortIndicator(key)}
|
||||
</th>`;
|
||||
}
|
||||
|
||||
function renderUnitEventStats(stats) {
|
||||
const s = stats || {};
|
||||
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
|
||||
@@ -2221,19 +2279,21 @@ function _ueEsc(s) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (a) {
|
||||
// Attributed: project / location link.
|
||||
const projLabel = _ueEsc(a.project_name || '—');
|
||||
const locLabel = _ueEsc(a.location_name || '—');
|
||||
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="text-seismo-orange hover:text-seismo-navy"
|
||||
title="${projLabel} → ${locLabel}">
|
||||
📍 ${locLabel}
|
||||
</a>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||
}
|
||||
// Unattributed: show nearest assignment + delta for context.
|
||||
const n = ev.nearest_assignment;
|
||||
if (n) {
|
||||
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
|
||||
@@ -2243,7 +2303,7 @@ function _ueAttrCell(ev) {
|
||||
: `${days.toFixed(1)}d`;
|
||||
return `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
|
||||
${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>`;
|
||||
}
|
||||
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>`;
|
||||
@@ -2267,7 +2327,8 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = events.map(ev => {
|
||||
const sorted = _ueApplySort(events);
|
||||
const rows = sorted.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const tran = _ueFmtPPV(ev.tran_ppv);
|
||||
const vert = _ueFmtPPV(ev.vert_ppv);
|
||||
@@ -2277,7 +2338,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>'
|
||||
: '';
|
||||
|
||||
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 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>
|
||||
@@ -2293,13 +2354,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
|
||||
${_ueSortableTh('Timestamp', 'timestamp')}
|
||||
${_ueSortableTh('Tran', 'tran_ppv')}
|
||||
${_ueSortableTh('Vert', 'vert_ppv')}
|
||||
${_ueSortableTh('Long', 'long_ppv')}
|
||||
${_ueSortableTh('PVS', 'peak_vector_sum')}
|
||||
${_ueSortableTh('Flags', 'false_trigger')}
|
||||
${_ueSortableTh('Attribution', 'attribution')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
@@ -2854,4 +2915,8 @@ function showToast(message, type = 'info') {
|
||||
</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 %}
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
<span id="ev-stat-count" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
|
||||
<span id="ev-stat-peak" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span id="ev-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
||||
</div>
|
||||
@@ -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>'
|
||||
: '';
|
||||
|
||||
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 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 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>
|
||||
@@ -953,4 +953,8 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeSwapModal();
|
||||
});
|
||||
</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 %}
|
||||
|
||||
Reference in New Issue
Block a user