Compare commits
8 Commits
ba1f28ee53
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f4fd1c943d | |||
| ba9cdb4347 | |||
| f063383e61 | |||
| 17c988c1ee | |||
| d297412d8a | |||
| 52dd6c3e32 | |||
| 295f9637b3 | |||
| ad55d4ca09 |
@@ -5,6 +5,57 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-05-15
|
||||||
|
|
||||||
|
Operator-facing polish release. All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events. Cascade-closes active unit assignments and cancels pending scheduled actions at the location. Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming). Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
|
||||||
|
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page. Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups. Click a bar to scroll the matching list row into view with a flash highlight.
|
||||||
|
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record). Now auto-detected and surfaceable in the timeline header — one click combines them into a single record. Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
|
||||||
|
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment. Trash icon in each row of the location's Deployment History panel. Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history. Writes an `assignment_deleted` UnitHistory row.
|
||||||
|
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left. Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`. Implementation uses native HTML5 drag-and-drop (no library). New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
|
||||||
|
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu. Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
|
||||||
|
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations still show session counts.
|
||||||
|
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map. One pin per active monitoring location (parsed from the `coordinates` field). Click pin → scrolls + flashes the matching card. Tooltip on hover. Locations without coordinates surface as an inline hint below the map. If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores. Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio. Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens. Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90. Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler. Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
|
||||||
|
- **Location Remove button** on the project page
|
||||||
|
- **Metadata-backfill typeahead dropdown** (existing project + location pickers)
|
||||||
|
- **Project-merge typeahead dropdown** (in the per-project header)
|
||||||
|
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
|
||||||
|
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`. Fixed by adding `isolation: isolate` to the map container.
|
||||||
|
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`. Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying. Both migrations are idempotent and non-destructive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or sweep all migrations at once (safe — already-applied ones no-op):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in backend/migrate_*.py; do
|
||||||
|
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
New columns added this release:
|
||||||
|
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
|
||||||
|
- `monitoring_locations.removal_reason` (TEXT, nullable)
|
||||||
|
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration
|
||||||
|
|
||||||
|
**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns. Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.10.0] - 2026-05-14
|
## [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.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.10.0
|
# Terra-View v0.11.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.
|
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
|
## Features
|
||||||
@@ -496,6 +496,19 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
|
### v0.11.0 — 2026-05-15
|
||||||
|
- **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page.
|
||||||
|
- **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view.
|
||||||
|
- **Merge Consecutive Deployments**: Auto-detects runs of same-location assignments within a 7-day gap and offers a one-click "Merge into one" button. Preserves notes, ingest source, and writes an `assignment_merged` audit entry.
|
||||||
|
- **Delete Assignment for Mis-Clicks**: Trash icon on each row of the location's Deployment History panel. Hard-deletes the assignment with a safety check that refuses if real MonitoringSessions sit inside the window (those should go through Unassign instead).
|
||||||
|
- **Drag-to-Reorder Location Cards**: Six-dot drag handle on each card; drop order persists via a new `/locations/reorder` endpoint. Removed locations stay sorted by removal date (their order is historical).
|
||||||
|
- **Three-Dot Kebab Menu**: Replaces the inline Unassign / Edit / Remove / Delete pill row with a single ⋮ menu. Much cleaner card layout, especially for projects with many locations.
|
||||||
|
- **Event Count on Vibration Cards**: Vibration locations now show "{N} events" instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations are unchanged.
|
||||||
|
- **Project Location Map**: Right column of the project overview is now a Leaflet map with a pin per location. Click pin → scrolls + flashes the matching card. Replaces the lightly-used Upcoming Actions panel (still discoverable via a link to the Schedules tab when actions exist).
|
||||||
|
- **Stricter Location Fuzzy Matching**: Metadata-backfill no longer suggests obviously-wrong matches. WRatio was over-confident on location names ("Area 2 - Brookville Dam - Loc 2" vs "Area 1 - Loc 1 - 87 Jenks" used to score 86%); now uses `token_set_ratio` + a multi-digit penalty so disjoint address numbers correctly demote the score.
|
||||||
|
- **Fixed: Multiple typeahead dropdowns weren't clickable**: Same JSON.stringify quote-collision bug surfaced in three places (location Remove button, backfill typeahead, project-merge dropdown). All three fixed by switching to `data-*` attributes + trampoline functions.
|
||||||
|
- **Fixed: Merge-project modal had to be scrolled to see options**: Modal body's `flex-1 overflow-y-auto` collapsed too tight; added `min-height` so the dropdown has room to render below the input.
|
||||||
|
|
||||||
### v0.10.0 — 2026-05-14
|
### 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 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.
|
- **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.
|
||||||
@@ -612,9 +625,11 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**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)
|
**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
|
||||||
|
|
||||||
Previous: 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
Previous: 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)
|
||||||
|
|
||||||
|
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.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.10.0"
|
VERSION = "0.11.0"
|
||||||
if ENVIRONMENT == "development":
|
if ENVIRONMENT == "development":
|
||||||
_build = os.getenv("BUILD_NUMBER", "0")
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
if _build and _build != "0":
|
if _build and _build != "0":
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `sort_order` column to `monitoring_locations` and seed
|
||||||
|
existing rows.
|
||||||
|
|
||||||
|
Lets operators reorder location cards via drag-and-drop on the project
|
||||||
|
detail page. Lower sort_order renders first; ties fall back to name.
|
||||||
|
|
||||||
|
Seed strategy: for each existing project, assign sort_order = 0, 1, 2, …
|
||||||
|
to its locations in their current alphabetical-by-name order. After
|
||||||
|
this migration, the visible card order on every existing project will
|
||||||
|
be unchanged.
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
|
||||||
|
cur.execute(f"PRAGMA table_info({table})")
|
||||||
|
return any(row[1] == column for row in cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database() -> None:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
added_column = False
|
||||||
|
if not _has_column(cur, "monitoring_locations", "sort_order"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN sort_order INTEGER DEFAULT 0")
|
||||||
|
added_column = True
|
||||||
|
print(" Added column: monitoring_locations.sort_order")
|
||||||
|
|
||||||
|
# Seed: for each project, set sort_order to its alphabetical index.
|
||||||
|
# Re-runs are harmless — operator-edited orderings can be re-seeded by
|
||||||
|
# passing FORCE_RESEED=1, but the default behavior leaves existing
|
||||||
|
# nonzero sort_order values alone so we don't clobber user choices.
|
||||||
|
force_reseed = os.environ.get("FORCE_RESEED") == "1"
|
||||||
|
if added_column or force_reseed:
|
||||||
|
cur.execute("SELECT DISTINCT project_id FROM monitoring_locations")
|
||||||
|
projects = [r[0] for r in cur.fetchall()]
|
||||||
|
seeded = 0
|
||||||
|
for project_id in projects:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM monitoring_locations WHERE project_id = ? ORDER BY name",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
for idx, (loc_id,) in enumerate(cur.fetchall()):
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_locations SET sort_order = ? WHERE id = ?",
|
||||||
|
(idx, loc_id),
|
||||||
|
)
|
||||||
|
seeded += 1
|
||||||
|
print(f" Seeded sort_order for {seeded} location(s) across {len(projects)} project(s).")
|
||||||
|
else:
|
||||||
|
print(" monitoring_locations.sort_order already present — leaving existing values alone.")
|
||||||
|
print(" (Set FORCE_RESEED=1 to re-seed by alphabetical order.)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add sort_order to monitoring_locations")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -243,6 +243,12 @@ class MonitoringLocation(Base):
|
|||||||
removed_at = Column(DateTime, nullable=True)
|
removed_at = Column(DateTime, nullable=True)
|
||||||
removal_reason = Column(Text, nullable=True)
|
removal_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Display order within the project's location list. Operators can
|
||||||
|
# drag-and-drop to reorder cards on the project detail page. Lower
|
||||||
|
# values render first; ties fall back to name (alphabetical). Seeded
|
||||||
|
# to alphabetical-index on migration; new locations get max+1.
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -376,7 +376,11 @@ def locations_search(
|
|||||||
if q_norm in l_norm:
|
if q_norm in l_norm:
|
||||||
scored.append((l, 1.0))
|
scored.append((l, 1.0))
|
||||||
continue
|
continue
|
||||||
score = svc.similarity(q_norm, l_norm)
|
# Use the location-specific scorer (token_set_ratio + multi-digit
|
||||||
|
# penalty) instead of WRatio — same reason as the cluster-match
|
||||||
|
# path: location names share too much boilerplate vocabulary for
|
||||||
|
# WRatio to discriminate reliably.
|
||||||
|
score = svc.location_similarity(q_norm, l_norm)
|
||||||
if score >= 0.50:
|
if score >= 0.50:
|
||||||
scored.append((l, score))
|
scored.append((l, score))
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,26 @@ async def get_project_locations(
|
|||||||
if location_type:
|
if location_type:
|
||||||
query = query.filter_by(location_type=location_type)
|
query = query.filter_by(location_type=location_type)
|
||||||
|
|
||||||
locations = query.order_by(MonitoringLocation.name).all()
|
# Order by operator-set sort_order, then name as a stable tie-breaker.
|
||||||
|
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
|
||||||
|
# For vibration locations, fan out event counts via SFM concurrently
|
||||||
|
# so the card layout can show "{N} events" instead of "Sessions: 0"
|
||||||
|
# (sessions don't really exist for the watcher-forward pipeline).
|
||||||
|
# Sound locations skip this and keep showing session counts.
|
||||||
|
event_counts: dict[str, int] = {}
|
||||||
|
vibration_locations = [l for l in locations if l.location_type == "vibration"]
|
||||||
|
if vibration_locations:
|
||||||
|
import asyncio
|
||||||
|
from backend.services.sfm_events import events_for_location
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(events_for_location(db, l.id, limit=1) for l in vibration_locations),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for loc, res in zip(vibration_locations, results):
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
continue # leave event_counts[loc.id] unset → template falls back
|
||||||
|
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0
|
||||||
|
|
||||||
# Enrich with assignment info, splitting active vs removed.
|
# Enrich with assignment info, splitting active vs removed.
|
||||||
active_data: list = []
|
active_data: list = []
|
||||||
@@ -183,6 +202,8 @@ async def get_project_locations(
|
|||||||
"assigned_unit": assigned_unit,
|
"assigned_unit": assigned_unit,
|
||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
}
|
}
|
||||||
|
if location.id in event_counts:
|
||||||
|
item["event_count"] = event_counts[location.id]
|
||||||
if location.removed_at is None:
|
if location.removed_at is None:
|
||||||
active_data.append(item)
|
active_data.append(item)
|
||||||
else:
|
else:
|
||||||
@@ -224,7 +245,7 @@ async def get_project_locations_json(
|
|||||||
if not include_removed:
|
if not include_removed:
|
||||||
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||||||
|
|
||||||
locations = query.order_by(MonitoringLocation.name).all()
|
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -256,6 +277,13 @@ async def create_location(
|
|||||||
|
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
|
# Compute next sort_order so new locations land at the END of the
|
||||||
|
# project's list rather than getting interleaved alphabetically.
|
||||||
|
from sqlalchemy import func
|
||||||
|
max_sort = db.query(func.max(MonitoringLocation.sort_order))\
|
||||||
|
.filter_by(project_id=project_id).scalar()
|
||||||
|
next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0
|
||||||
|
|
||||||
location = MonitoringLocation(
|
location = MonitoringLocation(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -265,6 +293,7 @@ async def create_location(
|
|||||||
coordinates=form_data.get("coordinates"),
|
coordinates=form_data.get("coordinates"),
|
||||||
address=form_data.get("address"),
|
address=form_data.get("address"),
|
||||||
location_metadata=form_data.get("location_metadata"), # JSON string
|
location_metadata=form_data.get("location_metadata"), # JSON string
|
||||||
|
sort_order=next_sort_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(location)
|
db.add(location)
|
||||||
@@ -356,6 +385,57 @@ async def delete_location(
|
|||||||
return {"success": True, "message": "Location deleted successfully"}
|
return {"success": True, "message": "Location deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/reorder")
|
||||||
|
async def reorder_locations(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Persist a new sort order for a project's monitoring locations.
|
||||||
|
|
||||||
|
Body JSON: { "location_ids": [uuid, uuid, ...] }
|
||||||
|
The list MUST contain location ids in the desired display order.
|
||||||
|
Locations not included in the list keep their current sort_order
|
||||||
|
(useful for the "active locations only — leave removed alone"
|
||||||
|
drag-and-drop UX).
|
||||||
|
|
||||||
|
Updates `sort_order` to the index of each id in the list. Ties
|
||||||
|
between included and excluded locations fall back to the existing
|
||||||
|
sort_order.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
ids = payload.get("location_ids") or []
|
||||||
|
if not isinstance(ids, list) or len(ids) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="location_ids must be a non-empty list")
|
||||||
|
|
||||||
|
# Fetch only the locations being reordered and validate ownership.
|
||||||
|
locations = db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.project_id == project_id,
|
||||||
|
MonitoringLocation.id.in_(ids),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
found_ids = {l.id for l in locations}
|
||||||
|
missing = [i for i in ids if i not in found_ids]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Some locations not found in this project: {missing[:3]}…",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply 0-indexed sort_order matching the operator's chosen order.
|
||||||
|
by_id = {l.id: l for l in locations}
|
||||||
|
for idx, loc_id in enumerate(ids):
|
||||||
|
by_id[loc_id].sort_order = idx
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "reordered": len(ids)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/locations/{location_id}/remove")
|
@router.post("/locations/{location_id}/remove")
|
||||||
async def remove_location(
|
async def remove_location(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ def similarity(a: str, b: str) -> float:
|
|||||||
too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the
|
too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the
|
||||||
strings don't exact-match. This guardrails the 'one common word
|
strings don't exact-match. This guardrails the 'one common word
|
||||||
inside a longer phrase' false positive.
|
inside a longer phrase' false positive.
|
||||||
|
|
||||||
|
USE FOR: project names (where typos like '1-80' vs 'I-80' should
|
||||||
|
still match). For location names use `location_similarity()` —
|
||||||
|
WRatio is too lenient on the shared boilerplate vocabulary in
|
||||||
|
location strings ('Area', 'Loc', 'Bridge', 'Dam', etc.).
|
||||||
"""
|
"""
|
||||||
if not a or not b:
|
if not a or not b:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -172,6 +177,50 @@ def similarity(a: str, b: str) -> float:
|
|||||||
return rapidfuzz.fuzz.WRatio(a, b) / 100.0
|
return rapidfuzz.fuzz.WRatio(a, b) / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# Multi-digit penalty applied when two location names have completely
|
||||||
|
# disjoint multi-digit numeric tokens (e.g. "87 Jenks" vs "68 Jenks").
|
||||||
|
# Single-digit numbers ("Loc 1", "Area 2") are often shared coincidentally,
|
||||||
|
# but address-style multi-digit numbers are strong identifiers — if they
|
||||||
|
# differ, the locations are usually different physical places.
|
||||||
|
_LOCATION_DIGIT_MISMATCH_PENALTY = 0.30
|
||||||
|
|
||||||
|
|
||||||
|
def location_similarity(a: str, b: str) -> float:
|
||||||
|
"""Stricter similarity score for location-name matching.
|
||||||
|
|
||||||
|
Location names share so much boilerplate vocabulary ('Area', 'Loc',
|
||||||
|
'Bridge', 'Dam') that rapidfuzz.WRatio inflates obvious mismatches.
|
||||||
|
Example: 'Area 2 - Brookville Dam - Loc 2 East' vs 'Area 1 - Loc 1 -
|
||||||
|
87 Jenks' scores 85.5 via WRatio despite being unrelated locations.
|
||||||
|
|
||||||
|
This scorer uses `token_set_ratio` as the base (sensitive to actual
|
||||||
|
word overlap, not just substring containment). It then applies a
|
||||||
|
multi-digit penalty: if both strings contain 2+-digit numbers and
|
||||||
|
none overlap, subtract 0.30. Catches the "same project, different
|
||||||
|
address-style identifier" case ('87 Jenks' vs '68 Jenks') that pure
|
||||||
|
token-set scoring still rates above 0.90.
|
||||||
|
|
||||||
|
Single-digit numbers ('Loc 1', 'Area 2') are excluded from the
|
||||||
|
penalty because they're often shared boilerplate ("Loc 1" in every
|
||||||
|
project) rather than discriminating identifiers.
|
||||||
|
"""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
if min(len(a), len(b)) < _MIN_FUZZY_LEN:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
base = rapidfuzz.fuzz.token_set_ratio(a, b) / 100.0
|
||||||
|
|
||||||
|
multidigits_a = set(re.findall(r"\d{2,}", a))
|
||||||
|
multidigits_b = set(re.findall(r"\d{2,}", b))
|
||||||
|
if multidigits_a and multidigits_b and not (multidigits_a & multidigits_b):
|
||||||
|
base = max(0.0, base - _LOCATION_DIGIT_MISMATCH_PENALTY)
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
# ── Cluster + Suggestion dataclasses ───────────────────────────────────────────
|
# ── Cluster + Suggestion dataclasses ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -572,15 +621,24 @@ async def _scan_clusters(
|
|||||||
def _find_best_match(
|
def _find_best_match(
|
||||||
candidate_norm: str,
|
candidate_norm: str,
|
||||||
candidates: list[tuple[str, str]], # (id, normalised_name)
|
candidates: list[tuple[str, str]], # (id, normalised_name)
|
||||||
|
*,
|
||||||
|
kind: str = "project", # "project" | "location"
|
||||||
) -> tuple[Optional[str], Optional[float], str]:
|
) -> tuple[Optional[str], Optional[float], str]:
|
||||||
"""Return (best_id, best_score, classification).
|
"""Return (best_id, best_score, classification).
|
||||||
|
|
||||||
classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"}
|
classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"}
|
||||||
|
|
||||||
|
The `kind` parameter selects the scorer. Project matching uses
|
||||||
|
rapidfuzz.WRatio (lenient — catches typos like '1-80' vs 'I-80').
|
||||||
|
Location matching uses `location_similarity` (stricter — catches
|
||||||
|
boilerplate-shared-but-actually-different strings like 'Loc 2 - 68
|
||||||
|
Jenks' vs 'Loc 1 - 87 Jenks').
|
||||||
"""
|
"""
|
||||||
if not candidate_norm or not candidates:
|
if not candidate_norm or not candidates:
|
||||||
return None, None, "no_match"
|
return None, None, "no_match"
|
||||||
|
|
||||||
scored = [(cid, similarity(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
scorer = location_similarity if kind == "location" else similarity
|
||||||
|
scored = [(cid, scorer(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
||||||
scored.sort(key=lambda x: x[1], reverse=True)
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
best_id, best_score = scored[0]
|
best_id, best_score = scored[0]
|
||||||
|
|
||||||
@@ -725,7 +783,7 @@ def _build_suggestion(db: Session, cluster: Cluster) -> Suggestion:
|
|||||||
)
|
)
|
||||||
location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs]
|
location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs]
|
||||||
if cluster.location_norm:
|
if cluster.location_norm:
|
||||||
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates)
|
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates, kind="location")
|
||||||
else:
|
else:
|
||||||
loc_id, loc_score, loc_match = None, None, "create_new"
|
loc_id, loc_score, loc_match = None, None, "create_new"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
<!-- Project Locations List — split into Active + Removed sections.
|
<!-- Project Locations List — Active + Removed sections.
|
||||||
Active locations get the full card with Assign/Edit/Delete/Remove
|
|
||||||
actions. Removed locations get a greyed-out card with a
|
Card layout:
|
||||||
Removed-on date, optional reason, and a Restore button. -->
|
[drag handle] [location info] [unit pill] [⋮ menu]
|
||||||
|
(name link, description, address, sessions/events, coords)
|
||||||
|
|
||||||
|
Active cards are draggable to reorder. Drop reorders the DOM
|
||||||
|
immediately and posts the new order to /api/projects/{p}/locations/reorder.
|
||||||
|
|
||||||
|
Removed cards are NOT reorderable (their order is historical) but
|
||||||
|
show a Restore button.
|
||||||
|
|
||||||
|
The three-dot menu replaces the inline Unassign/Edit/Remove/Delete
|
||||||
|
pill buttons. Click ⋮ to open; click outside closes.
|
||||||
|
-->
|
||||||
|
|
||||||
{% if not active_locations and not removed_locations %}
|
{% if not active_locations and not removed_locations %}
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
@@ -12,69 +23,110 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
{# ─── Active locations ─── #}
|
{# ─── Active locations (draggable) ─── #}
|
||||||
{% if active_locations %}
|
{% if active_locations %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3" id="active-locations-list" data-project-id="{{ project.id }}">
|
||||||
{% for item in active_locations %}
|
{% for item in active_locations %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors location-card"
|
||||||
|
draggable="true"
|
||||||
|
data-location-id="{{ item.location.id }}"
|
||||||
|
data-location-type="{{ item.location.location_type or 'sound' }}"
|
||||||
|
data-location-name="{{ item.location.name | e }}"
|
||||||
|
data-coordinates="{{ item.location.coordinates or '' }}"
|
||||||
|
ondragstart="onLocationDragStart(event)"
|
||||||
|
ondragover="onLocationDragOver(event)"
|
||||||
|
ondragleave="onLocationDragLeave(event)"
|
||||||
|
ondrop="onLocationDrop(event)"
|
||||||
|
ondragend="onLocationDragEnd(event)">
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Drag handle + info -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||||
|
<div class="shrink-0 pt-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
title="Drag to reorder">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 4a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 9a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 14a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||||
{{ item.location.name }}
|
{{ item.location.name }}
|
||||||
</a>
|
</a>
|
||||||
|
{% if item.location.description %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.address %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.coordinates %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
|
<span><strong class="text-gray-700 dark:text-gray-300">{{ "{:,}".format(item.event_count) }}</strong> event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Sessions: {{ item.session_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.assignment and item.assigned_unit %}
|
||||||
|
<span>Assigned: <a href="/unit/{{ item.assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy font-mono">{{ item.assigned_unit.id }}</a></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if item.location.description %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.location.address %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.location.coordinates %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Right column: small assign/unassign pill + 3-dot menu -->
|
||||||
{% if item.assignment %}
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<button onclick="unassignUnit('{{ item.assignment.id }}')"
|
{% if not item.assignment %}
|
||||||
class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
<!-- Primary action: visible because the unassigned card
|
||||||
Unassign
|
is most likely getting clicked on right after creation -->
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
|
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
|
||||||
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
|
||||||
onclick="openEditLocationModal(this)"
|
|
||||||
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button data-loc-id="{{ item.location.id }}"
|
|
||||||
data-loc-name="{{ item.location.name | e }}"
|
|
||||||
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName)"
|
|
||||||
class="text-xs px-3 py-1 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300 hover:bg-amber-100"
|
|
||||||
title="Mark as no longer actively monitored — preserves historical events">
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
<button onclick="deleteLocation('{{ item.location.id }}')"
|
|
||||||
class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
|
|
||||||
title="Permanently delete — only available if there's no history">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
<!-- Three-dot kebab menu -->
|
||||||
<span>Sessions: {{ item.session_count }}</span>
|
<div class="relative inline-block location-menu-wrapper">
|
||||||
{% if item.assignment and item.assigned_unit %}
|
<button onclick="toggleLocationMenu(event, this)"
|
||||||
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
class="p-1.5 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
{% else %}
|
title="More actions">
|
||||||
<span>No active assignment</span>
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
{% endif %}
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="location-menu hidden absolute right-0 mt-1 w-40 z-30 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||||
|
{% if item.assignment %}
|
||||||
|
<button onclick="unassignUnit('{{ item.assignment.id }}'); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
||||||
|
onclick="openEditLocationModal(this); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button data-loc-id="{{ item.location.id }}"
|
||||||
|
data-loc-name="{{ item.location.name | e }}"
|
||||||
|
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
||||||
|
title="Mark as no longer monitored — preserves events">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||||
|
<button onclick="deleteLocation('{{ item.location.id }}'); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
title="Permanently delete — only allowed if no history">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -135,7 +187,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
<span>Historical sessions: {{ item.session_count }}</span>
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
|
<span>{{ "{:,}".format(item.event_count) }} historical event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Historical sessions: {{ item.session_count }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -144,3 +200,103 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Drag-and-drop + menu handlers, scoped to this partial (re-defined
|
||||||
|
on every htmx swap, which is harmless — function declarations
|
||||||
|
overwrite). -->
|
||||||
|
<script>
|
||||||
|
let _dragSrcCard = null;
|
||||||
|
|
||||||
|
function onLocationDragStart(e) {
|
||||||
|
_dragSrcCard = e.currentTarget;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Required for Firefox to start the drag.
|
||||||
|
e.dataTransfer.setData('text/plain', _dragSrcCard.dataset.locationId);
|
||||||
|
e.currentTarget.classList.add('opacity-40');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragOver(e) {
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
e.currentTarget.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragLeave(e) {
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
|
||||||
|
const list = document.getElementById('active-locations-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Drop AFTER the target by default; if mouse is in top half, drop BEFORE.
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const dropBefore = (e.clientY - rect.top) < rect.height / 2;
|
||||||
|
if (dropBefore) {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget);
|
||||||
|
} else {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistLocationOrder(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragEnd(e) {
|
||||||
|
e.currentTarget.classList.remove('opacity-40');
|
||||||
|
document.querySelectorAll('.location-card').forEach(c => {
|
||||||
|
c.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
});
|
||||||
|
_dragSrcCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _persistLocationOrder(list) {
|
||||||
|
const projectId = list.dataset.projectId;
|
||||||
|
const ids = Array.from(list.querySelectorAll('.location-card'))
|
||||||
|
.map(c => c.dataset.locationId);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ location_ids: ids }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({ detail: 'HTTP ' + r.status }));
|
||||||
|
throw new Error(err.detail || 'reorder failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save new order:', err);
|
||||||
|
if (typeof showToast === 'function') showToast('Failed to save new order: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Three-dot menu ─────────────────────────────────────────────────
|
||||||
|
function toggleLocationMenu(e, btn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = btn.parentElement.querySelector('.location-menu');
|
||||||
|
const wasOpen = !menu.classList.contains('hidden');
|
||||||
|
closeAllLocationMenus();
|
||||||
|
if (!wasOpen) {
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllLocationMenus() {
|
||||||
|
document.querySelectorAll('.location-menu').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menus on outside click (only register once globally).
|
||||||
|
if (!window._locationMenuOutsideClickRegistered) {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.location-menu-wrapper')) closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
window._locationMenuOutsideClickRegistered = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -78,22 +78,128 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
overview. Operators get a quick visual of where their locations
|
||||||
{% if upcoming_actions %}
|
sit relative to each other. Pins clickable → scroll to + flash
|
||||||
<div class="space-y-3">
|
the matching card. Locations without coordinates land in a
|
||||||
{% for action in upcoming_actions %}
|
"missing coords" hint below the map.
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
For projects with scheduled monitoring activity, the full
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
Upcoming Actions list is still available on the Schedules tab. -->
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||||
{% if action.description %}
|
<div class="flex items-center justify-between mb-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
|
||||||
{% endif %}
|
{% if upcoming_actions %}
|
||||||
</div>
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
{% endfor %}
|
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
||||||
</div>
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
{% else %}
|
</a>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
|
{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
|
<!-- `isolation: isolate` forces a new stacking context so Leaflet's
|
||||||
|
internal z-indexes (panes at 200-700, controls at 800) stay
|
||||||
|
contained inside this div instead of leaking into the root
|
||||||
|
stacking context and rendering over modals (which have z-50). -->
|
||||||
|
<div id="project-location-map" class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
style="height: 320px; background: rgba(0,0,0,0.05); isolation: isolate;"></div>
|
||||||
|
<div id="project-location-map-empty" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
|
||||||
|
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
|
||||||
|
</div>
|
||||||
|
<div id="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Build location data from server-side render. Skip removed
|
||||||
|
// locations (their pins would clutter the active operations view)
|
||||||
|
// and skip ones without parseable coordinates.
|
||||||
|
const locationsRaw = [
|
||||||
|
{% for loc in locations %}
|
||||||
|
{% if not loc.removed_at %}
|
||||||
|
{
|
||||||
|
id: {{ loc.id | tojson }},
|
||||||
|
name: {{ loc.name | tojson }},
|
||||||
|
coords: {{ loc.coordinates | tojson if loc.coordinates else 'null' }},
|
||||||
|
}{% if not loop.last %},{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseCoords(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||||
|
if (parts.length !== 2 || parts.some(isNaN)) return null;
|
||||||
|
const [lat, lon] = parts;
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
|
||||||
|
const withCoords = [];
|
||||||
|
const withoutCoords = [];
|
||||||
|
for (const loc of locationsRaw) {
|
||||||
|
const xy = parseCoords(loc.coords);
|
||||||
|
if (xy) withCoords.push({ ...loc, latlon: xy });
|
||||||
|
else withoutCoords.push(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMsg = document.getElementById('project-location-map-empty');
|
||||||
|
const missingMsg = document.getElementById('project-location-map-missing');
|
||||||
|
const mapEl = document.getElementById('project-location-map');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
// Hide the map block and show a hint. Don't init Leaflet at all.
|
||||||
|
mapEl.classList.add('hidden');
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise Leaflet. `L` is loaded globally by base.html.
|
||||||
|
const map = L.map(mapEl, { scrollWheelZoom: false });
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markers = [];
|
||||||
|
const bounds = [];
|
||||||
|
withCoords.forEach(loc => {
|
||||||
|
const marker = L.circleMarker(loc.latlon, {
|
||||||
|
radius: 8,
|
||||||
|
fillColor: '#f48b1c',
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
}).addTo(map);
|
||||||
|
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
|
||||||
|
marker.on('click', () => _flashLocationCard(loc.id));
|
||||||
|
markers.push(marker);
|
||||||
|
bounds.push(loc.latlon);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds.length === 1) {
|
||||||
|
map.setView(bounds[0], 14);
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: [20, 20] });
|
||||||
|
}
|
||||||
|
// Without this the map renders into a 0×0 area when the partial
|
||||||
|
// first lands via htmx (container size not yet stable).
|
||||||
|
setTimeout(() => map.invalidateSize(), 100);
|
||||||
|
|
||||||
|
if (withoutCoords.length > 0) {
|
||||||
|
const names = withoutCoords.map(l => l.name).join(', ');
|
||||||
|
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
|
||||||
|
missingMsg.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Briefly highlight the matching card to confirm the click.
|
||||||
|
function _flashLocationCard(locId) {
|
||||||
|
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
card.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -87,9 +87,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Merge Modal -->
|
<!-- Merge Modal —
|
||||||
|
min-h on the body ensures the typeahead dropdown has room to render
|
||||||
|
below the input without forcing the operator to scroll inside the
|
||||||
|
modal. overflow-visible on the body lets the dropdown extend
|
||||||
|
beyond the body's natural height when needed. -->
|
||||||
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<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">
|
<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"
|
||||||
|
style="min-height: 480px;">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -104,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
<div class="px-6 py-4 overflow-y-auto flex-1 min-h-[320px]">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Target project
|
Target project
|
||||||
</label>
|
</label>
|
||||||
@@ -202,6 +207,10 @@ async function _mergeFetchTargets() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash target id + name in data-* attributes (NOT inline JS args)
|
||||||
|
// to avoid the quote-collision that breaks click binding when the
|
||||||
|
// project name contains characters JSON.stringify quotes. Same
|
||||||
|
// pattern as the backfill typeahead dropdown.
|
||||||
dropdown.innerHTML = candidates.map(m => {
|
dropdown.innerHTML = candidates.map(m => {
|
||||||
const scoreBadge = m.score >= 0.99
|
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-green-600 dark:text-green-400 ml-2">exact</span>'
|
||||||
@@ -212,8 +221,10 @@ async function _mergeFetchTargets() {
|
|||||||
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
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>` : '';
|
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||||
return `<button type="button"
|
return `<button type="button"
|
||||||
|
data-target-id="${_mergeEsc(m.id)}"
|
||||||
|
data-target-name="${_mergeEsc(m.name)}"
|
||||||
onmousedown="event.preventDefault()"
|
onmousedown="event.preventDefault()"
|
||||||
onclick="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
|
onclick="_mergePickFromButton(this)"
|
||||||
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">
|
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>
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
||||||
${metaLine}
|
${metaLine}
|
||||||
@@ -222,6 +233,13 @@ async function _mergeFetchTargets() {
|
|||||||
dropdown.classList.remove('hidden');
|
dropdown.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trampoline — reads the button's data attributes and forwards. Keeps
|
||||||
|
// the inline onclick free of any string interpolation that could break
|
||||||
|
// HTML quoting (see notes on the same pattern in metadata_backfill.html).
|
||||||
|
function _mergePickFromButton(btn) {
|
||||||
|
onMergePickTarget(btn.dataset.targetId, btn.dataset.targetName);
|
||||||
|
}
|
||||||
|
|
||||||
async function onMergePickTarget(targetId, targetName) {
|
async function onMergePickTarget(targetId, targetName) {
|
||||||
document.getElementById('merge-target-input').value = targetName;
|
document.getElementById('merge-target-input').value = targetName;
|
||||||
document.getElementById('merge-target-id').value = targetId;
|
document.getElementById('merge-target-id').value = targetId;
|
||||||
|
|||||||
Reference in New Issue
Block a user