Compare commits
110 Commits
v0.7.0
..
d64b9450a1
| Author | SHA1 | Date | |
|---|---|---|---|
| d64b9450a1 | |||
| a073b9b06e | |||
| 502bf5bbeb | |||
| 472c25372d | |||
| 6d37bd759e | |||
| 44ab4d8427 | |||
| aaf9399bb3 | |||
| ef6484c350 | |||
| 8cffd7dd5e | |||
| ba4cf9e560 | |||
| 1af5a94f57 | |||
| e05f2189c4 | |||
| 7ed94cd8fc | |||
| 2b8e9168c3 | |||
| 75597ec1c4 | |||
| 4dcfcbdc45 | |||
| 825c7370b8 | |||
| 47c65268e3 | |||
| ba9cdb4347 | |||
| f063383e61 | |||
| 17c988c1ee | |||
| d297412d8a | |||
| 52dd6c3e32 | |||
| 295f9637b3 | |||
| ad55d4ca09 | |||
| ba1f28ee53 | |||
| c48c6e5bca | |||
| ef0008822e | |||
| f13158e7bf | |||
| 3f0ec8f30b | |||
| d5a0163852 | |||
| fd37425f1c | |||
| 4378290c9c | |||
| 9775dca114 | |||
| 904ff04440 | |||
| 155f0b007a | |||
| 583af1948e | |||
| 449e031589 | |||
| 18fd0472a5 | |||
| e15481884a | |||
| 737901c962 | |||
| 2cf5bf47d3 | |||
| 77483c2186 | |||
| b1c2a1d778 | |||
| d3b5a3fd26 | |||
| d46f9fccf8 | |||
| 6ebbe28308 | |||
| 42de06f441 | |||
| 21844b4d65 | |||
| 80fa76208a | |||
| f1f3da8e61 | |||
| 63bd6ad8a2 | |||
| bc5a151faa | |||
| 09db988a35 | |||
| df771a87de | |||
| a71e6f5efd | |||
| ec661ee079 | |||
| 32d2a57bc9 | |||
| 63ba63edaf | |||
| 2ba20c7809 | |||
| f84d0818d2 | |||
| 3e0d20d62d | |||
| f50cf2b7f6 | |||
| 20e180644e | |||
| 73a6ff4d20 | |||
| 0f582a8a17 | |||
| 184f0ddd13 | |||
| e7bd09418b | |||
| 27eeb0fae6 | |||
| 192e15f238 | |||
| 49bc625c1a | |||
| 95fedca8c9 | |||
| e8e155556a | |||
| 33e962e73d | |||
| ac48fb2977 | |||
| 5e9cc32fdc | |||
| 3c4b81cf78 | |||
| d135727ebd | |||
| 64d4423308 | |||
| 4f71d528ce | |||
| 4f56dea4f3 | |||
| 40359db066 | |||
| 57a85f565b | |||
| e6555ba924 | |||
| 3d5b2fddef | |||
| 8694282dd0 | |||
| bc02dc9564 | |||
| 0d01715f81 | |||
| b3ec249c5e | |||
| b6e74258f1 | |||
| 5ea64c3561 | |||
| 1a87ff13c9 | |||
| 22c62c0729 | |||
| 0f47b69c92 | |||
| 76667454b3 | |||
| 0e3f512203 | |||
| 15d962ba42 | |||
| e4d1f0d684 | |||
| b571dc29bc | |||
| e2c841d5d7 | |||
| cc94493331 | |||
| 5a5426cceb | |||
| 66eddd6fe2 | |||
| c77794787c | |||
| 61c84bc71d | |||
| fbf7f2a65d | |||
| 202fcaf91c | |||
| 3a411d0a89 | |||
| 0c2186f5d8 | |||
| c138e8c6a0 |
+13
-1
@@ -1,6 +1,7 @@
|
|||||||
# Terra-View Specifics
|
# Terra-View Specifics
|
||||||
# Dev build counter (local only, never commit)
|
# Dev build counter (local only, never commit)
|
||||||
build_number.txt
|
build_number.txt
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
@@ -9,7 +10,7 @@ data/
|
|||||||
data-dev/
|
data-dev/
|
||||||
.aider*
|
.aider*
|
||||||
.aider*
|
.aider*
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -219,3 +220,14 @@ marimo/_static/
|
|||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
# Seismo Fleet Manager
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
/data/
|
||||||
|
/data-dev/
|
||||||
|
.aider*
|
||||||
|
.aider*
|
||||||
|
=======
|
||||||
|
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027
|
||||||
|
|||||||
+375
@@ -5,6 +5,381 @@ 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.12.1] - 2026-05-20
|
||||||
|
|
||||||
|
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
|
||||||
|
|
||||||
|
### Added — Unit Swap wizard (`/tools/unit-swap`)
|
||||||
|
|
||||||
|
- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm. Designed for tap-driven use on a phone in the field; works on desktop too.
|
||||||
|
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf. Each row carries a `deployed` boolean for badge rendering. Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
|
||||||
|
- **`POST /locations/{loc}/swap` enhancements**:
|
||||||
|
- Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` flag consistent with the active-assignment signal.
|
||||||
|
- Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), the stale back-reference is broken before re-pairing.
|
||||||
|
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
|
||||||
|
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.
|
||||||
|
|
||||||
|
### Added — Editable deployment timeline on `/unit/{id}`
|
||||||
|
|
||||||
|
- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline. Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes. Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
|
||||||
|
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment. Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
|
||||||
|
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows. Closed historical assignments that don't overlap an existing one are accepted (the backfill case).
|
||||||
|
- After any save/delete the timeline reloads and the SFM-events list re-fetches, so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`). The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever. All three now: close the previous active assignment, break the outgoing unit's modem pairing (both directions), and set `deployed = False` on the outgoing unit. Unassign and swap also clear the modem's back-reference. Promote-pending additionally handles the case where the target location already has an active assignment — previously this silently created two active assignments at the same location; now the old one is closed (`assigned_until = pending.capture_time`, `status = completed`), the old unit benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
|
||||||
|
- **Deployment timeline now respects user timezone for display *and* edits.** Timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen. Two-sided fix:
|
||||||
|
- **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing. Frontend slicing keeps working — it just slices a local-time string now.
|
||||||
|
- **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`. Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
No schema changes. Static code-only release — pull and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-05-17
|
||||||
|
|
||||||
|
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
|
||||||
|
|
||||||
|
### Added — field-deployment workflow
|
||||||
|
|
||||||
|
- **`/deploy` — mobile-first 3-step capture wizard**: pick unit → take photo (opens phone camera via `<input capture="environment">`) → optional note → submit. Designed for under-90-seconds-on-site. Success page shows captured coords and links back to "Deploy another" or the pending hopper.
|
||||||
|
- **`/tools/pending-deployments` — the hopper**: filter pills Awaiting / Assigned / Cancelled. Each card has photo thumbnail, unit link, coords, operator note, status-appropriate actions.
|
||||||
|
- **Classify modal**: two modes — assign to existing project+location, OR create new location with new-or-existing project + a "use captured coords" checkbox that writes the pending row's coords onto the new location record.
|
||||||
|
- **`PendingDeployment` data model** (`pending_deployments` table): lifecycle `awaiting → assigned | cancelled`. Photo file lives under `data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_<uuid8>.<ext>`. Migration: `backend/migrate_add_pending_deployments.py` (idempotent).
|
||||||
|
- **Backend endpoints**:
|
||||||
|
- `POST /api/deployments/capture` — multipart upload (unit_id, photo, optional note), EXIF GPS extraction, seismograph-only (rejects others with 400)
|
||||||
|
- `GET /api/deployments/pending` — list by status
|
||||||
|
- `GET /api/deployments/pending/{id}` — single row detail
|
||||||
|
- `POST /api/deployments/pending/{id}/promote` — classify and create `UnitAssignment`; events in the assignment window get retroactively attributed via the existing metadata-backfill mechanism
|
||||||
|
- `POST /api/deployments/pending/{id}/cancel` — abandon with optional reason
|
||||||
|
- `GET /api/deployments/seismograph-picker` — JSON list for the `/deploy` picker, annotated with `has_pending`
|
||||||
|
- **Discovery surfaces**: orange "Field Deploy" button on the desktop dashboard header (md+), bottom-nav slot 3 on mobile (Menu / Dashboard / Deploy / Events; Devices moved into the Menu drawer), `/tools` cards for both Field Deploy and Pending Deployments, dashboard banner that auto-shows when awaiting captures exist (polled every 30s, hides at 0).
|
||||||
|
- **Full audit trail**: every capture / promote / cancel writes a `UnitHistory` row (`pending_deployment_captured` / `_promoted` / `_cancelled`).
|
||||||
|
|
||||||
|
### Added — fleet-wide deployment history
|
||||||
|
|
||||||
|
- **`/tools/deployment-history` — fleet-wide 12-month calendar** (Phase 2 of the per-unit Gantt from v0.11.0). 4-month-per-row grid styled like the Job Planner, responsive to single column on mobile. Each day cell shows up to 4 deterministically-colored mini-bars (one per active project that day), with "+N" overflow. KPI strip across the top: project count, distinct unit count, total assignment count in the window. Collapsible project legend ordered by first-active date.
|
||||||
|
- **Click-a-day side panel**: slide-over from the right, groups by project, lists every (unit, location) active that day with auto-backfilled tags, sourced from new `GET /api/admin/deployment-history/day`.
|
||||||
|
- **Prev / Next / Recent month navigation**: shifts the 12-month window by 1 month. Default window is 11 months back from current → operator sees recent past on first load, not future emptiness.
|
||||||
|
- **Gantt by Project tab**: horizontal time-axis bars per project, hover for tooltip with unit + location + window. Reduced opacity for closed assignments, blue outline for metadata-backfilled, today dashed-orange line.
|
||||||
|
- **Gantt by Unit tab**: same idea inverted — one row per seismograph, bars colored by project. Natural for "where has BE11529 been across all my jobs?" Service layer returns a `units` array with bars carrying baked-in `project_color`.
|
||||||
|
- **Tab switcher with hash sync**: `#gantt` / `#byunit` preserved across month-paging. Tab registry (`_DH_TABS`) makes adding future views a one-line addition.
|
||||||
|
|
||||||
|
### Added — SFM event DB management
|
||||||
|
|
||||||
|
- **`/admin/events` — SFM Event DB Manager** under Developer Tools. Cross-unit event browser with filters (serial, from/to, false_trigger, limit), checkbox selection with select-all, and bulk actions:
|
||||||
|
- **Delete selected** — hard-delete chosen rows from SFM's `events` table
|
||||||
|
- **Delete ALL matching current filter** — dry-run first to show match count + sample serials in confirm dialog; only proceeds on explicit confirmation
|
||||||
|
- Same Flag-as-FT / Clear-FT bulk actions for convenience
|
||||||
|
- **Destructive operations also clean up on-disk files**: associated `.AB0*` blastware binary, `.a5.pkl`, `.sfm.json` sidecar, and `.h5` files are unlinked alongside the DB row. Cannot be undone — the manager has a prominent red warning banner and a `max_rows` safety cap (10,000) that refuses oversized deletes without explicit acknowledgment.
|
||||||
|
- **Designed for cleaning bogus events from a misbehaving unit** — a stuck-triggered seismograph can dump hundreds of junk events into SFM before it's recovered; this is the operator's broom.
|
||||||
|
- **`unit_detail.html` also gains bulk false-trigger flagging**: same checkbox UX as the DB manager, but with **🚩 Flag as false trigger** / **✓ Clear false trigger** instead of delete (delete is admin-only via `/admin/events`). Concurrent fan-out (8 in flight) for fast bulk PATCH.
|
||||||
|
|
||||||
|
### Added — maps, navigation, polish
|
||||||
|
|
||||||
|
- **Reusable location-map partial** (`templates/partials/projects/location_map.html`): self-contained map div + self-fetch script. Accepts `project_id`, `map_height`, `location_type` filter. Project overview's inline map (~150 lines of JS) replaced with a 1-line include; Vibration tab on the project detail page now uses the same partial with `location_type='vibration'` at 450px height.
|
||||||
|
- **Hover location card → highlight matching map pin** on the project overview map. Enlarges + reddens the pin, opens its tooltip. Bidirectional with the existing pin → card flash. Event delegation on `document` so cards from htmx swaps keep the behavior without rewiring.
|
||||||
|
- **Mobile bottom-nav swap Settings → Events**: Settings (rarely needed in the field) replaced by Events (the daily mobile destination since the SFM integration). Settings/Projects/Tools/admin pages still in the Menu drawer.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`/deploy` photo input now allows gallery picks**: `capture="environment"` was forcing mobile browsers to open the camera and skip "Photo Library" / "Choose File". Useful at the install site, problematic when uploading a photo taken earlier. Attribute removed; chooser now offers both options. EXIF extraction works identically.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
One new migration this release. Idempotent and non-destructive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.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 table: `pending_deployments` — capture rows for the field-deployment workflow. Empty after migration; populated as field techs use `/deploy`.
|
||||||
|
|
||||||
|
**Deploy order matters**: run the migration BEFORE the new code is up, or the running app will 500 on the missing table. Same gotcha that bit the v0.10.0 → v0.11.0 deploy.
|
||||||
|
|
||||||
|
**SFM bump pairing**: this release pairs with seismo-relay v0.17.0, which adds the `DELETE /db/events/{id}` and `POST /db/events/delete_bulk` endpoints that `/admin/events` consumes. An older SFM will return 405/404 for those routes; the manager will surface the error in its result alert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
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
|
||||||
|
- **Modular Project Types**: Projects now support optional modules (Sound Monitoring, Vibration Monitoring) selectable at creation time. The project header and dashboard dynamically show/hide tabs and actions based on which modules are enabled, and modules can be added or removed after creation.
|
||||||
|
- **Deleted Project Management**: Settings page now includes a section for soft-deleted projects with options to restore or permanently delete each one. Deleted projects load automatically when the Data tab is opened.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Swap Modal Search**: The unit/modem swap modal on vibration location detail pages now includes live search filtering for both seismographs and modems, making it easier to find the right unit in large fleets.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Roster Auto-Refresh No Longer Disrupts Scroll/Sort**: The roster page's 30-second background refresh now updates status, age, and last-seen values in-place via a lightweight JSON poll instead of replacing the entire table HTML. Sort order, scroll position, and active filters are all preserved across refreshes.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_project_modules.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.3] - 2026-03-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
|
||||||
|
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
|
||||||
|
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
|
||||||
|
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
|
||||||
|
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
|
||||||
|
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
|
||||||
|
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
|
||||||
|
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.2] - 2026-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
|
||||||
|
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
|
||||||
|
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
|
||||||
|
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
|
||||||
|
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
|
||||||
|
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_allocated.py
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-03-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
|
||||||
|
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
|
||||||
|
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
|
||||||
|
- **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
|
||||||
|
- **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
|
||||||
|
- **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
|
||||||
|
- **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
|
||||||
|
- **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
|
||||||
|
- **Available Units panel**: Shows units available for the job's date range when assigning
|
||||||
|
- **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
|
||||||
|
- **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
|
||||||
|
- **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
|
||||||
|
- **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
|
||||||
|
- **Calendar bar tooltips**: Hover any job bar to see job name and date range
|
||||||
|
- **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
|
||||||
|
- **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
|
||||||
|
- **Upcoming project status**: New `upcoming` status for projects promoted from reservations
|
||||||
|
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
|
||||||
|
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
|
||||||
|
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
|
||||||
|
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
|
||||||
|
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
|
||||||
|
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
|
||||||
|
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('/app/data/seismo_fleet.db')
|
||||||
|
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Watcher Manager**: New admin page (`/admin/watchers`) for monitoring field watcher agents
|
||||||
|
- Live status cards per agent showing connectivity, version, IP, last-seen age, and log tail
|
||||||
|
- Trigger Update button to queue a self-update on the agent's next heartbeat
|
||||||
|
- Expand/collapse log tail with full-log expand mode
|
||||||
|
- Live surgical refresh every 30 seconds via `/api/admin/watchers` — no full page reload, open logs stay open
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Watcher status logic**: Agent status now reflects whether Terra-View is hearing from the watcher (ok if seen within 60 minutes, missing otherwise) — previously reflected the worst unit status from the last heartbeat payload, which caused false alarms when units went missing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Watcher Manager meta row**: Dark mode background was white due to invalid `dark:bg-slate-850` Tailwind class; corrected to `dark:bg-slate-800`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.7.1] - 2026-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
|
||||||
|
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Migration Scripts**: Fixed database path references in several migration scripts
|
||||||
|
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run the following migration script once per database before deploying:
|
||||||
|
```bash
|
||||||
|
python backend/migrate_add_out_for_calibration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.7.0] - 2026-03-07
|
## [0.7.0] - 2026-03-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.7.0
|
# Terra-View v0.12.1
|
||||||
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
|
||||||
@@ -9,15 +9,20 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
|
|||||||
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
||||||
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
||||||
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
||||||
|
- **Field-Deployment Workflow**: One-photo mobile capture at `/deploy` → desk-side classification at `/tools/pending-deployments` → automatic UnitAssignment creation with EXIF GPS
|
||||||
|
- **Unit Swap Wizard** (`/tools/unit-swap`): mobile-first 4-step flow for swapping a vibration unit (and optionally its modem) at a monitoring location. Surfaces benched-fleet candidates as eligible incoming units; cleans up stale modem back-references on swap
|
||||||
|
- **Editable Deployment Timeline** on every unit detail page: inline edit / delete each assignment, plus an "Add deployment record" button for backfilling historical windows. Frees-up previously-orphaned events when their timestamp now falls inside an assignment
|
||||||
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
||||||
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
||||||
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
||||||
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
||||||
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
||||||
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
||||||
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
- **SFM Event DB Manager** (`/admin/events`): cross-unit event browser with bulk false-trigger flagging and admin-only hard-delete (cleans on-disk binaries + sidecars too) for purging bogus events from misbehaving units
|
||||||
|
- **Deployment-History Calendar + Gantt** (`/tools/deployment-history`): fleet-wide 12-month calendar with side-panel day drill-down, plus "Gantt by Project" / "Gantt by Unit" tabs
|
||||||
- **Photo Management**: Upload and view photos for each unit
|
- **Photo Management**: Upload and view photos for each unit
|
||||||
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab)
|
||||||
|
- **Timezone-Aware Timeline**: deployment assignments display and edit in the user's configured local timezone; UTC stays canonical on disk
|
||||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||||
- **Database Management**: Comprehensive backup and restore system
|
- **Database Management**: Comprehensive backup and restore system
|
||||||
- **Manual Snapshots**: Create on-demand backups with descriptions
|
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||||
@@ -496,6 +501,37 @@ 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
|
||||||
|
- **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
|
||||||
|
- **Live Refresh**: Watcher Manager surgically patches status, last-seen, and pending indicators every 30s without a full page reload
|
||||||
|
|
||||||
### v0.7.0 — 2026-03-07
|
### v0.7.0 — 2026-03-07
|
||||||
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
|
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
|
||||||
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
|
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
|
||||||
@@ -594,9 +630,27 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.7.0** — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
**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.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
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.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)
|
||||||
|
|
||||||
|
0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
||||||
|
|
||||||
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
||||||
|
|
||||||
|
|||||||
+143
-6
@@ -18,7 +18,7 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, sfm, projects, project_locations, scheduler, modem_dashboard
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
from backend.utils.timezone import get_user_timezone
|
from backend.utils.timezone import get_user_timezone
|
||||||
@@ -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.7.0"
|
VERSION = "0.12.1"
|
||||||
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":
|
||||||
@@ -97,11 +97,24 @@ app.include_router(slmm.router)
|
|||||||
app.include_router(slm_ui.router)
|
app.include_router(slm_ui.router)
|
||||||
app.include_router(slm_dashboard.router)
|
app.include_router(slm_dashboard.router)
|
||||||
app.include_router(seismo_dashboard.router)
|
app.include_router(seismo_dashboard.router)
|
||||||
|
app.include_router(sfm.router)
|
||||||
app.include_router(modem_dashboard.router)
|
app.include_router(modem_dashboard.router)
|
||||||
|
|
||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
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)
|
||||||
|
|
||||||
|
from backend.routers import deployment_history
|
||||||
|
app.include_router(deployment_history.router)
|
||||||
|
|
||||||
|
from backend.routers import pending_deployments
|
||||||
|
app.include_router(pending_deployments.router)
|
||||||
|
|
||||||
# Projects system routers
|
# Projects system routers
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
@@ -111,6 +124,10 @@ app.include_router(scheduler.router)
|
|||||||
from backend.routers import report_templates
|
from backend.routers import report_templates
|
||||||
app.include_router(report_templates.router)
|
app.include_router(report_templates.router)
|
||||||
|
|
||||||
|
# Metadata-backfill admin router (Phase 5a)
|
||||||
|
from backend.routers import metadata_backfill
|
||||||
|
app.include_router(metadata_backfill.router)
|
||||||
|
|
||||||
# Alerts router
|
# Alerts router
|
||||||
from backend.routers import alerts
|
from backend.routers import alerts
|
||||||
app.include_router(alerts.router)
|
app.include_router(alerts.router)
|
||||||
@@ -123,6 +140,10 @@ app.include_router(recurring_schedules.router)
|
|||||||
from backend.routers import fleet_calendar
|
from backend.routers import fleet_calendar
|
||||||
app.include_router(fleet_calendar.router)
|
app.include_router(fleet_calendar.router)
|
||||||
|
|
||||||
|
# Deployment Records router
|
||||||
|
from backend.routers import deployments
|
||||||
|
app.include_router(deployments.router)
|
||||||
|
|
||||||
# Start scheduler service and device status monitor on application startup
|
# Start scheduler service and device status monitor on application startup
|
||||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||||
@@ -226,6 +247,58 @@ async def seismographs_page(request: Request):
|
|||||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sfm", response_class=HTMLResponse)
|
||||||
|
async def sfm_page(request: Request):
|
||||||
|
"""SFM live event data and device control dashboard"""
|
||||||
|
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("/deploy", response_class=HTMLResponse)
|
||||||
|
async def deploy_page(request: Request):
|
||||||
|
"""Mobile-first field-capture wizard. Pick a seismograph, snap a
|
||||||
|
photo of the install, optionally add a memo — drop into the pending
|
||||||
|
hopper for later classification."""
|
||||||
|
return templates.TemplateResponse("deploy.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/tools/pending-deployments", response_class=HTMLResponse)
|
||||||
|
async def pending_deployments_page(request: Request):
|
||||||
|
"""List of field captures awaiting classification, plus filters for
|
||||||
|
historical assigned / cancelled rows. Operators promote a capture
|
||||||
|
into a real UnitAssignment from here."""
|
||||||
|
return templates.TemplateResponse("admin/pending_deployments.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/tools/unit-swap", response_class=HTMLResponse)
|
||||||
|
async def unit_swap_page(request: Request):
|
||||||
|
"""Mobile-first wizard for swapping a vibration unit (and optionally its
|
||||||
|
modem) at a monitoring location. Pick project → location → incoming
|
||||||
|
unit → modem decision → confirm → optional photo of the new install."""
|
||||||
|
return templates.TemplateResponse("admin/unit_swap.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/modems", response_class=HTMLResponse)
|
@app.get("/modems", response_class=HTMLResponse)
|
||||||
async def modems_page(request: Request):
|
async def modems_page(request: Request):
|
||||||
"""Field modems management dashboard"""
|
"""Field modems management dashboard"""
|
||||||
@@ -348,8 +421,11 @@ async def nrl_detail_page(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
assigned_unit = None
|
assigned_unit = None
|
||||||
|
assigned_modem = None
|
||||||
if assignment:
|
if assignment:
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
if assigned_unit and assigned_unit.deployed_with_modem_id:
|
||||||
|
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
|
||||||
|
|
||||||
# Get session count
|
# Get session count
|
||||||
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||||
@@ -386,6 +462,7 @@ async def nrl_detail_page(
|
|||||||
"location": location,
|
"location": location,
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
"assigned_unit": assigned_unit,
|
"assigned_unit": assigned_unit,
|
||||||
|
"assigned_modem": assigned_modem,
|
||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
"file_count": file_count,
|
"file_count": file_count,
|
||||||
"active_session": active_session,
|
"active_session": active_session,
|
||||||
@@ -659,6 +736,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": unit_data.get("last", "Never"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": True,
|
"deployed": True,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -683,6 +761,59 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": unit_data.get("last", "Never"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add allocated units
|
||||||
|
for unit_id, unit_data in snapshot.get("allocated", {}).items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": "Allocated",
|
||||||
|
"age": "N/A",
|
||||||
|
"last_seen": "N/A",
|
||||||
|
"deployed": False,
|
||||||
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
|
"allocated": True,
|
||||||
|
"allocated_to_project_id": unit_data.get("allocated_to_project_id", ""),
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add out-for-calibration units
|
||||||
|
for unit_id, unit_data in snapshot["out_for_calibration"].items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": "Out for Calibration",
|
||||||
|
"age": "N/A",
|
||||||
|
"last_seen": "N/A",
|
||||||
|
"deployed": False,
|
||||||
|
"retired": False,
|
||||||
|
"out_for_calibration": True,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -707,6 +838,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": True,
|
"retired": True,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -731,6 +863,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": True,
|
"ignored": True,
|
||||||
"note": unit_data.get("note", unit_data.get("reason", "")),
|
"note": unit_data.get("note", unit_data.get("reason", "")),
|
||||||
"device_type": unit_data.get("device_type", "unknown"),
|
"device_type": unit_data.get("device_type", "unknown"),
|
||||||
@@ -748,15 +881,19 @@ async def devices_all_partial(request: Request):
|
|||||||
|
|
||||||
# Sort by status category, then by ID
|
# Sort by status category, then by ID
|
||||||
def sort_key(unit):
|
def sort_key(unit):
|
||||||
# Priority: deployed (active) -> benched -> retired -> ignored
|
# Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored
|
||||||
if unit["deployed"]:
|
if unit["deployed"]:
|
||||||
return (0, unit["id"])
|
return (0, unit["id"])
|
||||||
elif not unit["retired"] and not unit["ignored"]:
|
elif unit.get("allocated"):
|
||||||
return (1, unit["id"])
|
return (1, unit["id"])
|
||||||
elif unit["retired"]:
|
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
|
||||||
return (2, unit["id"])
|
return (2, unit["id"])
|
||||||
else:
|
elif unit["out_for_calibration"]:
|
||||||
return (3, unit["id"])
|
return (3, unit["id"])
|
||||||
|
elif unit["retired"]:
|
||||||
|
return (4, unit["id"])
|
||||||
|
else:
|
||||||
|
return (5, unit["id"])
|
||||||
|
|
||||||
units_list.sort(key=sort_key)
|
units_list.sort(key=sort_key)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add allocated and allocated_to_project_id columns to roster table.
|
||||||
|
Run once: python backend/migrate_add_allocated.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db')
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Check existing columns
|
||||||
|
cur.execute("PRAGMA table_info(roster)")
|
||||||
|
cols = {row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if 'allocated' not in cols:
|
||||||
|
cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL")
|
||||||
|
print("Added column: allocated")
|
||||||
|
else:
|
||||||
|
print("Column already exists: allocated")
|
||||||
|
|
||||||
|
if 'allocated_to_project_id' not in cols:
|
||||||
|
cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR")
|
||||||
|
print("Added column: allocated_to_project_id")
|
||||||
|
else:
|
||||||
|
print("Column already exists: allocated_to_project_id")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add deployment_records table.
|
||||||
|
|
||||||
|
Tracks each time a unit is sent to the field and returned.
|
||||||
|
The active deployment is the row with actual_removal_date IS NULL.
|
||||||
|
|
||||||
|
Run once per database:
|
||||||
|
python backend/migrate_add_deployment_records.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if table already exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='deployment_records'
|
||||||
|
""")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("✓ deployment_records table already exists, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Creating deployment_records table...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE deployment_records (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
unit_id TEXT NOT NULL,
|
||||||
|
deployed_date DATE,
|
||||||
|
estimated_removal_date DATE,
|
||||||
|
actual_removal_date DATE,
|
||||||
|
project_ref TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
location_name TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX idx_deployment_records_unit_id
|
||||||
|
ON deployment_records(unit_id)
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX idx_deployment_records_project_id
|
||||||
|
ON deployment_records(project_id)
|
||||||
|
""")
|
||||||
|
# Index for finding active deployments quickly
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX idx_deployment_records_active
|
||||||
|
ON deployment_records(unit_id, actual_removal_date)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✓ deployment_records table created successfully")
|
||||||
|
print("✓ Indexes created")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add estimated_units to job_reservations
|
||||||
|
|
||||||
|
Adds column:
|
||||||
|
- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Default database path (matches production pattern)
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if job_reservations table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("job_reservations table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get existing columns in job_reservations
|
||||||
|
cursor.execute("PRAGMA table_info(job_reservations)")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Add estimated_units column if it doesn't exist
|
||||||
|
if 'estimated_units' not in existing_cols:
|
||||||
|
print("Adding estimated_units column to job_reservations...")
|
||||||
|
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER")
|
||||||
|
else:
|
||||||
|
print("estimated_units column already exists. Skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = DB_PATH
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `removed_at` + `removal_reason` columns to `monitoring_locations`.
|
||||||
|
|
||||||
|
Lets operators mark a location as no longer actively monitored without
|
||||||
|
deleting it (so historical events stay attributed correctly). Mirrors
|
||||||
|
the timestamp-based "closed state" pattern already used by
|
||||||
|
`unit_assignments.assigned_until`.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- `removed_at IS NULL` → location is active (default for all existing
|
||||||
|
rows after this migration)
|
||||||
|
- `removed_at` set → location is removed; historical events still
|
||||||
|
attribute to it but it's hidden from active
|
||||||
|
surfaces (assign dropdowns, calendar, etc.)
|
||||||
|
- `removal_reason` → optional operator note (e.g. "client dropped
|
||||||
|
from scope")
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.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 = []
|
||||||
|
if not _has_column(cur, "monitoring_locations", "removed_at"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removed_at DATETIME")
|
||||||
|
added.append("removed_at")
|
||||||
|
if not _has_column(cur, "monitoring_locations", "removal_reason"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removal_reason TEXT")
|
||||||
|
added.append("removal_reason")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns to monitoring_locations: {', '.join(added)}")
|
||||||
|
else:
|
||||||
|
print(" monitoring_locations already has removed_at + removal_reason — nothing to do.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add removed_at + removal_reason to monitoring_locations")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add location_slots column to job_reservations table.
|
||||||
|
Stores the full ordered slot list (including empty/unassigned slots) as JSON.
|
||||||
|
Run once per database.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/seismo_fleet.db")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
existing = [r[1] for r in cursor.execute("PRAGMA table_info(job_reservations)").fetchall()]
|
||||||
|
if "location_slots" not in existing:
|
||||||
|
cursor.execute("ALTER TABLE job_reservations ADD COLUMN location_slots TEXT")
|
||||||
|
conn.commit()
|
||||||
|
print("Added location_slots column to job_reservations.")
|
||||||
|
else:
|
||||||
|
print("location_slots column already exists, skipping.")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -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.")
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Database Migration: Add out_for_calibration field to roster table
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table
|
||||||
|
- Safe to run multiple times (idempotent)
|
||||||
|
- No data loss
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/migrate_add_out_for_calibration.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
print("=" * 60)
|
||||||
|
print("Migration: Add out_for_calibration to roster")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Check if column already exists
|
||||||
|
result = db.execute(text("PRAGMA table_info(roster)")).fetchall()
|
||||||
|
columns = [row[1] for row in result]
|
||||||
|
|
||||||
|
if "out_for_calibration" in columns:
|
||||||
|
print("Column out_for_calibration already exists. Skipping.")
|
||||||
|
else:
|
||||||
|
db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE"))
|
||||||
|
db.commit()
|
||||||
|
print("Added out_for_calibration column to roster table.")
|
||||||
|
|
||||||
|
print("Migration complete.")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `pending_deployments` table.
|
||||||
|
|
||||||
|
Stores "I just installed this seismograph" captures from the field.
|
||||||
|
A pending deployment is the prospective form of a UnitAssignment —
|
||||||
|
captured at install time (photo + coords + maybe a free-text note),
|
||||||
|
classified later (project + location chosen at a desk).
|
||||||
|
|
||||||
|
Once classified, a real UnitAssignment is created, the pending row's
|
||||||
|
status flips to "assigned", and resulting_assignment_id points at the
|
||||||
|
new assignment for audit.
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_deployments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
unit_id TEXT NOT NULL,
|
||||||
|
captured_at DATETIME NOT NULL,
|
||||||
|
coordinates TEXT,
|
||||||
|
operator_note TEXT,
|
||||||
|
photo_filename TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'awaiting',
|
||||||
|
promoted_at DATETIME,
|
||||||
|
resulting_assignment_id TEXT,
|
||||||
|
cancelled_at DATETIME,
|
||||||
|
cancelled_reason TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" Table 'pending_deployments' ready.")
|
||||||
|
|
||||||
|
# Indexes — operators will query by status (hopper list) and by
|
||||||
|
# unit_id (per-unit detail page → "is there a pending capture?").
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_deployments_status
|
||||||
|
ON pending_deployments (status)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_deployments_unit_id
|
||||||
|
ON pending_deployments (unit_id)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_deployments_captured_at
|
||||||
|
ON pending_deployments (captured_at)
|
||||||
|
""")
|
||||||
|
print(" Indexes ready.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add pending_deployments table")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -44,7 +44,7 @@ def migrate(db_path: str):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db_path = "./data/terra-view.db"
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
db_path = sys.argv[1]
|
db_path = sys.argv[1]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add project_modules table and seed from existing project_type_id values.
|
||||||
|
|
||||||
|
Safe to run multiple times — idempotent.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "seismo_fleet.db")
|
||||||
|
DB_PATH = os.path.abspath(DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Create project_modules table if not exists
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS project_modules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
module_type TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(project_id, module_type)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" Table 'project_modules' ready.")
|
||||||
|
|
||||||
|
# 2. Seed modules from existing project_type_id values
|
||||||
|
cur.execute("SELECT id, project_type_id FROM projects WHERE project_type_id IS NOT NULL")
|
||||||
|
projects = cur.fetchall()
|
||||||
|
|
||||||
|
seeded = 0
|
||||||
|
for p in projects:
|
||||||
|
pid = p["id"]
|
||||||
|
ptype = p["project_type_id"]
|
||||||
|
|
||||||
|
modules_to_add = []
|
||||||
|
if ptype == "sound_monitoring":
|
||||||
|
modules_to_add = ["sound_monitoring"]
|
||||||
|
elif ptype == "vibration_monitoring":
|
||||||
|
modules_to_add = ["vibration_monitoring"]
|
||||||
|
elif ptype == "combined":
|
||||||
|
modules_to_add = ["sound_monitoring", "vibration_monitoring"]
|
||||||
|
|
||||||
|
for module_type in modules_to_add:
|
||||||
|
# INSERT OR IGNORE — skip if already exists
|
||||||
|
cur.execute("""
|
||||||
|
INSERT OR IGNORE INTO project_modules (id, project_id, module_type, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)
|
||||||
|
""", (str(uuid.uuid4()), pid, module_type))
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
seeded += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f" Seeded {seeded} module record(s) from existing project_type_id values.")
|
||||||
|
|
||||||
|
# 3. Make project_type_id nullable (SQLite doesn't support ALTER COLUMN,
|
||||||
|
# but since we're just loosening a constraint this is a no-op in SQLite —
|
||||||
|
# the column already accepts NULL in practice. Nothing to do.)
|
||||||
|
print(" project_type_id column is now treated as nullable (legacy field).")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Migration: add period_start_hour and period_end_hour to monitoring_sessions.
|
||||||
|
|
||||||
|
Run once:
|
||||||
|
python backend/migrate_add_session_period_hours.py
|
||||||
|
|
||||||
|
Or inside the container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from backend.database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check which columns already exist
|
||||||
|
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||||
|
existing = {row[1] for row in result}
|
||||||
|
|
||||||
|
added = []
|
||||||
|
for col, definition in [
|
||||||
|
("period_start_hour", "INTEGER"),
|
||||||
|
("period_end_hour", "INTEGER"),
|
||||||
|
]:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||||
|
added.append(col)
|
||||||
|
else:
|
||||||
|
print(f" Column '{col}' already exists — skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns: {', '.join(added)}")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Migration: add report_date to monitoring_sessions.
|
||||||
|
|
||||||
|
Run once:
|
||||||
|
python backend/migrate_add_session_report_date.py
|
||||||
|
|
||||||
|
Or inside the container:
|
||||||
|
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from backend.database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check which columns already exist
|
||||||
|
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||||
|
existing = {row[1] for row in result}
|
||||||
|
|
||||||
|
added = []
|
||||||
|
for col, definition in [
|
||||||
|
("report_date", "DATE"),
|
||||||
|
]:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||||
|
added.append(col)
|
||||||
|
else:
|
||||||
|
print(f" Column '{col}' already exists — skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns: {', '.join(added)}")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Migration: deprecate the `deployment_records` table.
|
||||||
|
|
||||||
|
Why:
|
||||||
|
The deployment-history view on the unit detail page used to render
|
||||||
|
from `deployment_records` — a manually-maintained table that drifted
|
||||||
|
out of sync with `unit_assignments` (the auto-written project/location
|
||||||
|
assignment table). That caused the "wonky timeline" symptom: missing
|
||||||
|
entries, duplicate / contradictory rows, and a UI that couldn't tell
|
||||||
|
the operator what the unit was actually doing during each window.
|
||||||
|
|
||||||
|
Phase 4 of the SFM integration replaces the deployment-history view
|
||||||
|
with a derived timeline computed from `unit_assignments` +
|
||||||
|
`unit_history` + SFM event overlay. This migration is the cleanup:
|
||||||
|
|
||||||
|
1. Adds a `deprecated_at` timestamp column to `deployment_records` so
|
||||||
|
we can mark rows that have been migrated.
|
||||||
|
2. For every `deployment_records` row that does NOT have a matching
|
||||||
|
`unit_assignments` row (matched by unit_id + overlapping date
|
||||||
|
range), synthesizes a best-effort UnitAssignment row. The
|
||||||
|
free-text `location_name` from the legacy table is preserved on
|
||||||
|
the new row's `notes` field (we do NOT try to fuzzy-match it to a
|
||||||
|
MonitoringLocation id; too error-prone — operators will need to
|
||||||
|
reattach those manually if they want).
|
||||||
|
3. Marks every migrated deployment_records row with `deprecated_at`.
|
||||||
|
|
||||||
|
This migration is non-destructive: deployment_records rows stay in
|
||||||
|
the DB. The actual `DROP TABLE` happens in a follow-up release after
|
||||||
|
one operator cycle confirms nothing relies on the legacy data.
|
||||||
|
|
||||||
|
Idempotent: re-running the script is a no-op if the column already
|
||||||
|
exists and all migratable rows have already been processed.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_deprecate_deployment_records.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Migrating database: {DB_PATH}")
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Add deprecated_at column if not present.
|
||||||
|
cur.execute("PRAGMA table_info(deployment_records)")
|
||||||
|
cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
if "deprecated_at" not in cols:
|
||||||
|
print("Adding deployment_records.deprecated_at column ...")
|
||||||
|
cur.execute("ALTER TABLE deployment_records ADD COLUMN deprecated_at TEXT")
|
||||||
|
conn.commit()
|
||||||
|
else:
|
||||||
|
print("deployment_records.deprecated_at column already exists — skipping ADD COLUMN")
|
||||||
|
|
||||||
|
# 2. Find candidate rows: not-yet-deprecated deployment_records that
|
||||||
|
# have no matching unit_assignments row.
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, unit_id, deployed_date, estimated_removal_date,
|
||||||
|
actual_removal_date, project_id, project_ref, location_name, notes
|
||||||
|
FROM deployment_records
|
||||||
|
WHERE deprecated_at IS NULL
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print(f"\nFound {len(rows)} deployment_records rows not yet deprecated.")
|
||||||
|
|
||||||
|
backfilled = 0
|
||||||
|
skipped_no_match_attempted = 0
|
||||||
|
skipped_already_in_assignments = 0
|
||||||
|
skipped_missing_unit = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
unit_id = row["unit_id"]
|
||||||
|
if not unit_id:
|
||||||
|
print(f" ⚠ row {row['id']!r}: no unit_id, marking deprecated without backfill")
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||||
|
(datetime.utcnow().isoformat(), row["id"]),
|
||||||
|
)
|
||||||
|
skipped_missing_unit += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Does the unit still exist? If not, skip — we don't synthesize
|
||||||
|
# assignments for ghost units.
|
||||||
|
cur.execute("SELECT id, device_type FROM roster WHERE id=?", (unit_id,))
|
||||||
|
roster = cur.fetchone()
|
||||||
|
if not roster:
|
||||||
|
print(f" ⚠ row {row['id']!r}: unit_id {unit_id!r} not in roster, marking deprecated without backfill")
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||||
|
(datetime.utcnow().isoformat(), row["id"]),
|
||||||
|
)
|
||||||
|
skipped_missing_unit += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if a UnitAssignment already covers this window (any overlap).
|
||||||
|
# We don't try to be clever — just see if a row exists for this unit
|
||||||
|
# whose [assigned_at, assigned_until] overlaps the deployment window.
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM unit_assignments
|
||||||
|
WHERE unit_id=?
|
||||||
|
AND (assigned_at <= COALESCE(?, '9999')
|
||||||
|
AND COALESCE(assigned_until, '9999') >= COALESCE(?, '0000'))
|
||||||
|
LIMIT 1
|
||||||
|
""", (
|
||||||
|
unit_id,
|
||||||
|
row["actual_removal_date"] or row["estimated_removal_date"] or row["deployed_date"],
|
||||||
|
row["deployed_date"],
|
||||||
|
))
|
||||||
|
if cur.fetchone():
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||||
|
(datetime.utcnow().isoformat(), row["id"]),
|
||||||
|
)
|
||||||
|
skipped_already_in_assignments += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# No matching UnitAssignment — synthesize one. We can't FK to a
|
||||||
|
# MonitoringLocation because the legacy `location_name` is free
|
||||||
|
# text. Backfilled rows go in with location_id = "" (empty) and
|
||||||
|
# the original location_name dropped into notes for operator
|
||||||
|
# context.
|
||||||
|
if not row["project_id"]:
|
||||||
|
print(f" ⚠ row {row['id']!r}: no project_id, can't synthesize unit_assignment, marking deprecated")
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||||
|
(datetime.utcnow().isoformat(), row["id"]),
|
||||||
|
)
|
||||||
|
skipped_no_match_attempted += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
synthesized_id = str(uuid.uuid4())
|
||||||
|
synth_notes_parts = []
|
||||||
|
if row["location_name"]:
|
||||||
|
synth_notes_parts.append(f"Legacy location: {row['location_name']}")
|
||||||
|
if row["project_ref"]:
|
||||||
|
synth_notes_parts.append(f"Legacy project_ref: {row['project_ref']}")
|
||||||
|
if row["notes"]:
|
||||||
|
synth_notes_parts.append(f"Original notes: {row['notes']}")
|
||||||
|
synth_notes_parts.append(f"(Synthesized from deployment_records row {row['id']})")
|
||||||
|
synth_notes = " | ".join(synth_notes_parts)
|
||||||
|
|
||||||
|
assigned_until = row["actual_removal_date"]
|
||||||
|
# Don't auto-close active deployments based on estimated_removal_date.
|
||||||
|
status = "completed" if assigned_until else "active"
|
||||||
|
|
||||||
|
# Need a location_id to satisfy NOT NULL constraint. Use a
|
||||||
|
# placeholder UUID so the FK can be cleaned up later if the
|
||||||
|
# operator decides to retarget the assignment to a real location.
|
||||||
|
# We tag this with the synthesized notes so it's discoverable.
|
||||||
|
placeholder_loc_id = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO unit_assignments (
|
||||||
|
id, unit_id, location_id, project_id, device_type,
|
||||||
|
assigned_at, assigned_until, status, notes, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
synthesized_id,
|
||||||
|
unit_id,
|
||||||
|
placeholder_loc_id,
|
||||||
|
row["project_id"],
|
||||||
|
roster["device_type"] or "seismograph",
|
||||||
|
row["deployed_date"] or datetime.utcnow().isoformat(),
|
||||||
|
assigned_until,
|
||||||
|
status,
|
||||||
|
synth_notes,
|
||||||
|
datetime.utcnow().isoformat(),
|
||||||
|
))
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||||
|
(datetime.utcnow().isoformat(), row["id"]),
|
||||||
|
)
|
||||||
|
backfilled += 1
|
||||||
|
print(
|
||||||
|
f" ✓ row {row['id']!r}: synthesized unit_assignment {synthesized_id} "
|
||||||
|
f"for unit={unit_id} project={row['project_id'][:8]}… "
|
||||||
|
f"({row['deployed_date']} → {assigned_until or 'present'})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ row {row['id']!r}: failed to synthesize — {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n────────────────────────────────────────────────────────")
|
||||||
|
print(f"Backfilled new unit_assignments: {backfilled}")
|
||||||
|
print(f"Already covered (deprecated only): {skipped_already_in_assignments}")
|
||||||
|
print(f"No project_id (deprecated only): {skipped_no_match_attempted}")
|
||||||
|
print(f"Missing/orphaned unit (deprecated): {skipped_missing_unit}")
|
||||||
|
print(f"\nNOTE: synthesized rows have an empty location_id and the legacy")
|
||||||
|
print(f" free-text location is preserved in notes. An operator should")
|
||||||
|
print(f" retarget them to real MonitoringLocation rows if they want")
|
||||||
|
print(f" events to show up on a location detail page.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
@@ -42,7 +42,7 @@ def migrate(db_path: str):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db_path = "./data/terra-view.db"
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
db_path = sys.argv[1]
|
db_path = sys.argv[1]
|
||||||
|
|||||||
+207
-2
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer, UniqueConstraint
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from backend.database import Base
|
from backend.database import Base
|
||||||
|
|
||||||
@@ -32,6 +32,9 @@ class RosterUnit(Base):
|
|||||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
||||||
deployed = Column(Boolean, default=True)
|
deployed = Column(Boolean, default=True)
|
||||||
retired = Column(Boolean, default=False)
|
retired = Column(Boolean, default=False)
|
||||||
|
out_for_calibration = Column(Boolean, default=False)
|
||||||
|
allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed
|
||||||
|
allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to
|
||||||
note = Column(String, nullable=True)
|
note = Column(String, nullable=True)
|
||||||
project_id = Column(String, nullable=True)
|
project_id = Column(String, nullable=True)
|
||||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||||
@@ -65,6 +68,26 @@ class RosterUnit(Base):
|
|||||||
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||||
|
|
||||||
|
|
||||||
|
class WatcherAgent(Base):
|
||||||
|
"""
|
||||||
|
Watcher agents: tracks the watcher processes (series3-watcher, thor-watcher)
|
||||||
|
that run on field machines and report unit heartbeats.
|
||||||
|
|
||||||
|
Updated on every heartbeat received from each source_id.
|
||||||
|
"""
|
||||||
|
__tablename__ = "watcher_agents"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # source_id (hostname)
|
||||||
|
source_type = Column(String, nullable=False) # series3_watcher | series4_watcher
|
||||||
|
version = Column(String, nullable=True) # e.g. "1.4.0"
|
||||||
|
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||||
|
status = Column(String, nullable=False, default="unknown") # ok | pending | missing | error | unknown
|
||||||
|
ip_address = Column(String, nullable=True)
|
||||||
|
log_tail = Column(Text, nullable=True) # last N log lines (JSON array of strings)
|
||||||
|
update_pending = Column(Boolean, default=False) # set True to trigger remote update
|
||||||
|
update_version = Column(String, nullable=True) # target version to update to
|
||||||
|
|
||||||
|
|
||||||
class IgnoredUnit(Base):
|
class IgnoredUnit(Base):
|
||||||
"""
|
"""
|
||||||
Ignored units: units that report but should be filtered out from unknown emitters.
|
Ignored units: units that report but should be filtered out from unknown emitters.
|
||||||
@@ -154,7 +177,7 @@ class Project(Base):
|
|||||||
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
||||||
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=True) # Legacy FK to ProjectType.id; use ProjectModule for feature flags
|
||||||
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
||||||
|
|
||||||
# Data collection mode: how field data reaches Terra-View.
|
# Data collection mode: how field data reaches Terra-View.
|
||||||
@@ -174,6 +197,22 @@ class Project(Base):
|
|||||||
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
|
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectModule(Base):
|
||||||
|
"""
|
||||||
|
Modules enabled on a project. Each module unlocks a set of features/tabs.
|
||||||
|
A project can have zero or more modules (sound_monitoring, vibration_monitoring, etc.).
|
||||||
|
"""
|
||||||
|
__tablename__ = "project_modules"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
|
||||||
|
project_id = Column(String, nullable=False, index=True) # FK to projects.id
|
||||||
|
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
|
||||||
|
enabled = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
||||||
|
|
||||||
|
|
||||||
class MonitoringLocation(Base):
|
class MonitoringLocation(Base):
|
||||||
"""
|
"""
|
||||||
Monitoring locations: generic location for monitoring activities.
|
Monitoring locations: generic location for monitoring activities.
|
||||||
@@ -196,6 +235,20 @@ class MonitoringLocation(Base):
|
|||||||
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
||||||
location_metadata = Column(Text, nullable=True)
|
location_metadata = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Soft-removal: NULL means active. When set, the location is hidden from
|
||||||
|
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
|
||||||
|
# vibration summary) but historical events generated before this time
|
||||||
|
# still attribute to it. Mirrors the closed-state pattern used by
|
||||||
|
# UnitAssignment.assigned_until.
|
||||||
|
removed_at = Column(DateTime, 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)
|
||||||
|
|
||||||
@@ -220,9 +273,48 @@ class UnitAssignment(Base):
|
|||||||
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||||
|
|
||||||
|
# Provenance: how was this assignment created? Used for auditing,
|
||||||
|
# bulk-undo of parser actions, and the Phase 4 deployment timeline.
|
||||||
|
# "manual" — operator created via UI
|
||||||
|
# "metadata_backfill" — auto-created by the metadata parser
|
||||||
|
# from operator-typed BW event metadata
|
||||||
|
# (bulk backfill workflow)
|
||||||
|
# "metadata_backfill_swap" — auto-created by swap-detection
|
||||||
|
# background job
|
||||||
|
source = Column(String, nullable=False, default="manual")
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataBackfillDecision(Base):
|
||||||
|
"""
|
||||||
|
Per-cluster decisions tracked by the metadata-backfill parser.
|
||||||
|
|
||||||
|
`cluster_id` is the deterministic SHA1 hash of
|
||||||
|
(serial, first_event_date, last_event_date), so the same cluster
|
||||||
|
produces the same id across re-scans. The decisions table lets the
|
||||||
|
parser remember "I already applied this" or "operator skipped this"
|
||||||
|
across scan invocations.
|
||||||
|
"""
|
||||||
|
__tablename__ = "metadata_backfill_decisions"
|
||||||
|
|
||||||
|
cluster_id = Column(String, primary_key=True)
|
||||||
|
status = Column(String, nullable=False) # pending | applied | skipped | conflict
|
||||||
|
confidence = Column(String, nullable=False) # high | medium | low
|
||||||
|
decided_at = Column(DateTime, nullable=True)
|
||||||
|
decided_by = Column(String, nullable=True) # background | operator | auto-high
|
||||||
|
applied_assignment_id = Column(String, nullable=True) # FK to unit_assignments.id
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
first_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
serial = Column(String, nullable=False, index=True)
|
||||||
|
project_raw = Column(String, nullable=True)
|
||||||
|
location_raw = Column(String, nullable=True)
|
||||||
|
first_event_ts = Column(DateTime, nullable=True)
|
||||||
|
last_event_ts = Column(DateTime, nullable=True)
|
||||||
|
event_count = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|
||||||
class ScheduledAction(Base):
|
class ScheduledAction(Base):
|
||||||
"""
|
"""
|
||||||
Scheduled actions: automation for recording start/stop/download.
|
Scheduled actions: automation for recording start/stop/download.
|
||||||
@@ -280,6 +372,17 @@ class MonitoringSession(Base):
|
|||||||
# weekday_day | weekday_night | weekend_day | weekend_night
|
# weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
period_type = Column(String, nullable=True)
|
period_type = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Effective monitoring window (hours 0–23). Night sessions cross midnight
|
||||||
|
# (period_end_hour < period_start_hour). NULL = no filtering applied.
|
||||||
|
# e.g. Day: start=7, end=19 Night: start=19, end=7
|
||||||
|
period_start_hour = Column(Integer, nullable=True)
|
||||||
|
period_end_hour = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# For day sessions: the specific calendar date to use for report filtering.
|
||||||
|
# Overrides the automatic "last date with daytime rows" heuristic.
|
||||||
|
# Null = use heuristic.
|
||||||
|
report_date = Column(Date, nullable=True)
|
||||||
|
|
||||||
# Snapshot of device configuration at recording time
|
# Snapshot of device configuration at recording time
|
||||||
session_metadata = Column(Text, nullable=True) # JSON
|
session_metadata = Column(Text, nullable=True) # JSON
|
||||||
|
|
||||||
@@ -427,6 +530,41 @@ class Alert(Base):
|
|||||||
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Deployment Records
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class DeploymentRecord(Base):
|
||||||
|
"""
|
||||||
|
Deployment records: tracks each time a unit is sent to the field and returned.
|
||||||
|
|
||||||
|
Each row represents one deployment. The active deployment is the record
|
||||||
|
with actual_removal_date IS NULL. The fleet calendar uses this to show
|
||||||
|
units as "In Field" and surface their expected return date.
|
||||||
|
|
||||||
|
project_ref is a freeform string for legacy/vibration jobs like "Fay I-80".
|
||||||
|
project_id will be populated once those jobs are migrated to proper Project records.
|
||||||
|
"""
|
||||||
|
__tablename__ = "deployment_records"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||||
|
|
||||||
|
deployed_date = Column(Date, nullable=True) # When unit left the yard
|
||||||
|
estimated_removal_date = Column(Date, nullable=True) # Expected return date
|
||||||
|
actual_removal_date = Column(Date, nullable=True) # Filled in when returned; NULL = still out
|
||||||
|
|
||||||
|
# Project linkage: freeform for legacy jobs, FK for proper project records
|
||||||
|
project_ref = Column(String, nullable=True) # e.g. "Fay I-80" (vibration jobs)
|
||||||
|
project_id = Column(String, nullable=True, index=True) # FK to Project.id (when available)
|
||||||
|
|
||||||
|
location_name = Column(String, nullable=True) # e.g. "North Gate", "VP-001"
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Fleet Calendar & Job Reservations
|
# Fleet Calendar & Job Reservations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -459,6 +597,11 @@ class JobReservation(Base):
|
|||||||
# For quantity reservations
|
# For quantity reservations
|
||||||
device_type = Column(String, default="seismograph") # seismograph | slm
|
device_type = Column(String, default="seismograph") # seismograph | slm
|
||||||
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
||||||
|
estimated_units = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Full slot list as JSON: [{"location_name": "North Gate", "unit_id": null}, ...]
|
||||||
|
# Includes empty slots (no unit assigned yet). Filled slots are authoritative in JobReservationUnit.
|
||||||
|
location_slots = Column(Text, nullable=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
@@ -494,3 +637,65 @@ class JobReservationUnit(Base):
|
|||||||
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
||||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||||
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
||||||
|
|
||||||
|
# Power requirements for this deployment slot
|
||||||
|
power_type = Column(String, nullable=True) # "ac" | "solar" | None
|
||||||
|
|
||||||
|
# Location identity
|
||||||
|
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||||
|
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeployment(Base):
|
||||||
|
"""
|
||||||
|
Field-captured "I just installed this seismograph" record waiting
|
||||||
|
to be classified into a project + location.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
1. Operator captures from the /deploy mobile page — photo (EXIF
|
||||||
|
GPS auto-extracted), optional free-text note. Row created
|
||||||
|
with status="awaiting".
|
||||||
|
2. Later, at a desk: operator picks a project + location (existing
|
||||||
|
or new) and "promotes" the row. A real UnitAssignment is
|
||||||
|
created, this row's status flips to "assigned", and
|
||||||
|
resulting_assignment_id points at the new assignment.
|
||||||
|
3. Mistakes / abandoned captures → status="cancelled" with a
|
||||||
|
cancelled_reason for audit.
|
||||||
|
|
||||||
|
Events emitted by the unit before classification are NOT auto-
|
||||||
|
attributed (no UnitAssignment exists yet). They land in the
|
||||||
|
"unattributed" bucket on the unit's events tab. Once the pending
|
||||||
|
deployment is promoted, the new UnitAssignment's window
|
||||||
|
retroactively attributes them — same mechanism the metadata-
|
||||||
|
backfill tool uses.
|
||||||
|
|
||||||
|
Seismograph-only for v1. SLM deployments don't follow the same
|
||||||
|
"field-install + verify call-home" pattern and are tracked
|
||||||
|
elsewhere.
|
||||||
|
"""
|
||||||
|
__tablename__ = "pending_deployments"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit
|
||||||
|
captured_at = Column(DateTime, nullable=False) # When the photo was taken
|
||||||
|
coordinates = Column(String, nullable=True) # "lat,lon" from photo EXIF
|
||||||
|
operator_note = Column(Text, nullable=True) # Free text — site memo
|
||||||
|
|
||||||
|
# Path under data/photos/{unit_id}/. Just the filename; the unit
|
||||||
|
# context lives in unit_id.
|
||||||
|
photo_filename = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Lifecycle.
|
||||||
|
# "awaiting" — captured, not yet classified
|
||||||
|
# "assigned" — promoted to a UnitAssignment
|
||||||
|
# "cancelled" — operator marked it as a mistake / abandoned
|
||||||
|
status = Column(String, nullable=False, default="awaiting", index=True)
|
||||||
|
|
||||||
|
promoted_at = Column(DateTime, nullable=True)
|
||||||
|
resulting_assignment_id = Column(String, nullable=True) # FK to UnitAssignment when promoted
|
||||||
|
|
||||||
|
cancelled_at = Column(DateTime, nullable=True)
|
||||||
|
cancelled_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|||||||
@@ -4,11 +4,29 @@ from sqlalchemy import desc
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["activity"])
|
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")
|
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,
|
"hours": hours,
|
||||||
"time_threshold": time_threshold.isoformat()
|
"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,218 @@
|
|||||||
|
"""
|
||||||
|
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("/admin/events", response_class=HTMLResponse)
|
||||||
|
def admin_events_page(request: Request):
|
||||||
|
"""SFM Event DB Manager — browse, flag, and delete events across all units."""
|
||||||
|
return templates.TemplateResponse("admin_events.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,99 @@
|
|||||||
|
"""
|
||||||
|
Fleet-wide deployment-history calendar — Phase 2 of the
|
||||||
|
deployment-history visualisation work (Phase 1 is the per-unit Gantt
|
||||||
|
on /unit/{id}).
|
||||||
|
|
||||||
|
Renders all UnitAssignment windows across all projects on a 12-month
|
||||||
|
calendar grid styled like the Job Planner. Each day cell shows one
|
||||||
|
mini-bar per project that had ≥1 active assignment that day. Click a
|
||||||
|
day → side panel with the (unit, location) pairs active.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /tools/deployment-history — HTML page
|
||||||
|
GET /api/admin/deployment-history/day — JSON list of deployments
|
||||||
|
on a specific date (used
|
||||||
|
by the day-detail panel)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services.deployment_history import (
|
||||||
|
get_deployment_history_data,
|
||||||
|
get_deployments_on_day,
|
||||||
|
)
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools/deployment-history", response_class=HTMLResponse)
|
||||||
|
def deployment_history_page(
|
||||||
|
request: Request,
|
||||||
|
year: Optional[int] = Query(None),
|
||||||
|
month: Optional[int] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Fleet-wide deployment history calendar.
|
||||||
|
|
||||||
|
Defaults to a 12-month window ending in the current month (so the
|
||||||
|
operator sees the recent past, not the future). ?year=&month= can
|
||||||
|
override the START of the window to scroll backward or forward.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
# Default: 12-month window ending this month → start = 11 months back.
|
||||||
|
if year is None or month is None:
|
||||||
|
# 11 months back from current month.
|
||||||
|
m = today.month - 11
|
||||||
|
y = today.year
|
||||||
|
while m < 1:
|
||||||
|
m += 12
|
||||||
|
y -= 1
|
||||||
|
start_year, start_month = y, m
|
||||||
|
else:
|
||||||
|
start_year, start_month = year, month
|
||||||
|
|
||||||
|
calendar = get_deployment_history_data(db, start_year, start_month)
|
||||||
|
|
||||||
|
# Build prev/next navigation values.
|
||||||
|
prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1)
|
||||||
|
next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("admin/deployment_history.html", {
|
||||||
|
"request": request,
|
||||||
|
"calendar": calendar,
|
||||||
|
"today": today.isoformat(),
|
||||||
|
"prev_year": prev_y,
|
||||||
|
"prev_month": prev_m,
|
||||||
|
"next_year": next_y,
|
||||||
|
"next_month": next_m,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/deployment-history/day")
|
||||||
|
def deployment_history_day(
|
||||||
|
target_date: str = Query(..., description="YYYY-MM-DD"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return assignments active on a specific calendar day."""
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(target_date)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": f"Invalid date: {target_date!r}"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
deployments = get_deployments_on_day(db, d)
|
||||||
|
return JSONResponse({
|
||||||
|
"date": target_date,
|
||||||
|
"count": len(deployments),
|
||||||
|
"deployments": deployments,
|
||||||
|
})
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import DeploymentRecord, RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["deployments"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(record: DeploymentRecord) -> dict:
|
||||||
|
return {
|
||||||
|
"id": record.id,
|
||||||
|
"unit_id": record.unit_id,
|
||||||
|
"deployed_date": record.deployed_date.isoformat() if record.deployed_date else None,
|
||||||
|
"estimated_removal_date": record.estimated_removal_date.isoformat() if record.estimated_removal_date else None,
|
||||||
|
"actual_removal_date": record.actual_removal_date.isoformat() if record.actual_removal_date else None,
|
||||||
|
"project_ref": record.project_ref,
|
||||||
|
"project_id": record.project_id,
|
||||||
|
"location_name": record.location_name,
|
||||||
|
"notes": record.notes,
|
||||||
|
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||||
|
"updated_at": record.updated_at.isoformat() if record.updated_at else None,
|
||||||
|
"is_active": record.actual_removal_date is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deployments/{unit_id}")
|
||||||
|
def get_deployments(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get all deployment records for a unit, newest first."""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
records = (
|
||||||
|
db.query(DeploymentRecord)
|
||||||
|
.filter_by(unit_id=unit_id)
|
||||||
|
.order_by(DeploymentRecord.deployed_date.desc(), DeploymentRecord.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {"deployments": [_serialize(r) for r in records]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deployments/{unit_id}/active")
|
||||||
|
def get_active_deployment(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get the current active deployment (actual_removal_date is NULL), or null."""
|
||||||
|
record = (
|
||||||
|
db.query(DeploymentRecord)
|
||||||
|
.filter(
|
||||||
|
DeploymentRecord.unit_id == unit_id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
)
|
||||||
|
.order_by(DeploymentRecord.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return {"deployment": _serialize(record) if record else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/deployments/{unit_id}")
|
||||||
|
def create_deployment(unit_id: str, payload: dict, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Create a new deployment record for a unit.
|
||||||
|
|
||||||
|
Body fields (all optional):
|
||||||
|
deployed_date (YYYY-MM-DD)
|
||||||
|
estimated_removal_date (YYYY-MM-DD)
|
||||||
|
project_ref (freeform string)
|
||||||
|
project_id (UUID if linked to Project)
|
||||||
|
location_name
|
||||||
|
notes
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
def parse_date(val) -> Optional[date]:
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
if isinstance(val, date):
|
||||||
|
return val
|
||||||
|
return date.fromisoformat(str(val))
|
||||||
|
|
||||||
|
record = DeploymentRecord(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=unit_id,
|
||||||
|
deployed_date=parse_date(payload.get("deployed_date")),
|
||||||
|
estimated_removal_date=parse_date(payload.get("estimated_removal_date")),
|
||||||
|
actual_removal_date=None,
|
||||||
|
project_ref=payload.get("project_ref"),
|
||||||
|
project_id=payload.get("project_id"),
|
||||||
|
location_name=payload.get("location_name"),
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return _serialize(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/deployments/{unit_id}/{deployment_id}")
|
||||||
|
def update_deployment(unit_id: str, deployment_id: str, payload: dict, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Update a deployment record. Used for:
|
||||||
|
- Setting/changing estimated_removal_date
|
||||||
|
- Closing a deployment (set actual_removal_date to mark unit returned)
|
||||||
|
- Editing project_ref, location_name, notes
|
||||||
|
"""
|
||||||
|
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Deployment record not found")
|
||||||
|
|
||||||
|
def parse_date(val) -> Optional[date]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if val == "":
|
||||||
|
return None
|
||||||
|
if isinstance(val, date):
|
||||||
|
return val
|
||||||
|
return date.fromisoformat(str(val))
|
||||||
|
|
||||||
|
if "deployed_date" in payload:
|
||||||
|
record.deployed_date = parse_date(payload["deployed_date"])
|
||||||
|
if "estimated_removal_date" in payload:
|
||||||
|
record.estimated_removal_date = parse_date(payload["estimated_removal_date"])
|
||||||
|
if "actual_removal_date" in payload:
|
||||||
|
record.actual_removal_date = parse_date(payload["actual_removal_date"])
|
||||||
|
if "project_ref" in payload:
|
||||||
|
record.project_ref = payload["project_ref"]
|
||||||
|
if "project_id" in payload:
|
||||||
|
record.project_id = payload["project_id"]
|
||||||
|
if "location_name" in payload:
|
||||||
|
record.location_name = payload["location_name"]
|
||||||
|
if "notes" in payload:
|
||||||
|
record.notes = payload["notes"]
|
||||||
|
|
||||||
|
record.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return _serialize(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/deployments/{unit_id}/{deployment_id}")
|
||||||
|
def delete_deployment(unit_id: str, deployment_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Delete a deployment record."""
|
||||||
|
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Deployment record not found")
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -19,7 +19,7 @@ import logging
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
RosterUnit, JobReservation, JobReservationUnit,
|
RosterUnit, JobReservation, JobReservationUnit,
|
||||||
UserPreferences, Project
|
UserPreferences, Project, MonitoringLocation, UnitAssignment
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
from backend.services.fleet_calendar_service import (
|
from backend.services.fleet_calendar_service import (
|
||||||
@@ -61,9 +61,53 @@ async def fleet_calendar_page(
|
|||||||
|
|
||||||
# Get projects for the reservation form dropdown
|
# Get projects for the reservation form dropdown
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status == "active"
|
Project.status.in_(["active", "upcoming", "on_hold"])
|
||||||
).order_by(Project.name).all()
|
).order_by(Project.name).all()
|
||||||
|
|
||||||
|
# Build a serializable list of items with dates for calendar bars
|
||||||
|
# Includes both tracked Projects (with dates) and Job Reservations (matching device_type)
|
||||||
|
project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316']
|
||||||
|
# Map calendar device_type to project_type_ids
|
||||||
|
device_type_to_project_types = {
|
||||||
|
"seismograph": ["vibration_monitoring", "combined"],
|
||||||
|
"slm": ["sound_monitoring", "combined"],
|
||||||
|
}
|
||||||
|
relevant_project_types = device_type_to_project_types.get(device_type, [])
|
||||||
|
|
||||||
|
calendar_projects = []
|
||||||
|
for i, p in enumerate(projects):
|
||||||
|
if p.start_date and p.project_type_id in relevant_project_types:
|
||||||
|
calendar_projects.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"start_date": p.start_date.isoformat(),
|
||||||
|
"end_date": p.end_date.isoformat() if p.end_date else None,
|
||||||
|
"color": project_colors[i % len(project_colors)],
|
||||||
|
"confirmed": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add job reservations for this device_type as bars
|
||||||
|
from sqlalchemy import or_ as _or
|
||||||
|
cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1)
|
||||||
|
reservations_for_cal = db.query(JobReservation).filter(
|
||||||
|
JobReservation.device_type == device_type,
|
||||||
|
JobReservation.start_date <= cal_window_end,
|
||||||
|
_or(
|
||||||
|
JobReservation.end_date >= date(year, month, 1),
|
||||||
|
JobReservation.end_date == None,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for res in reservations_for_cal:
|
||||||
|
end = res.end_date or res.estimated_end_date
|
||||||
|
calendar_projects.append({
|
||||||
|
"id": res.id,
|
||||||
|
"name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat(),
|
||||||
|
"end_date": end.isoformat() if end else None,
|
||||||
|
"color": res.color,
|
||||||
|
"confirmed": bool(res.project_id),
|
||||||
|
})
|
||||||
|
|
||||||
# Calculate prev/next month navigation
|
# Calculate prev/next month navigation
|
||||||
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||||
@@ -81,6 +125,7 @@ async def fleet_calendar_page(
|
|||||||
"device_type": device_type,
|
"device_type": device_type,
|
||||||
"calendar_data": calendar_data,
|
"calendar_data": calendar_data,
|
||||||
"projects": projects,
|
"projects": projects,
|
||||||
|
"calendar_projects": calendar_projects,
|
||||||
"today": today.isoformat()
|
"today": today.isoformat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -167,6 +212,7 @@ async def create_reservation(
|
|||||||
if estimated_end_date and estimated_end_date < start_date:
|
if estimated_end_date and estimated_end_date < start_date:
|
||||||
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
|
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
|
||||||
|
|
||||||
|
import json as _json
|
||||||
reservation = JobReservation(
|
reservation = JobReservation(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -178,6 +224,8 @@ async def create_reservation(
|
|||||||
assignment_type=data["assignment_type"],
|
assignment_type=data["assignment_type"],
|
||||||
device_type=data.get("device_type", "seismograph"),
|
device_type=data.get("device_type", "seismograph"),
|
||||||
quantity_needed=data.get("quantity_needed"),
|
quantity_needed=data.get("quantity_needed"),
|
||||||
|
estimated_units=data.get("estimated_units"),
|
||||||
|
location_slots=_json.dumps(data["location_slots"]) if data.get("location_slots") is not None else None,
|
||||||
notes=data.get("notes"),
|
notes=data.get("notes"),
|
||||||
color=data.get("color", "#3B82F6")
|
color=data.get("color", "#3B82F6")
|
||||||
)
|
)
|
||||||
@@ -221,8 +269,16 @@ async def get_reservation(
|
|||||||
reservation_id=reservation_id
|
reservation_id=reservation_id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
unit_ids = [a.unit_id for a in assignments]
|
# Sort assignments by slot_index so order is preserved
|
||||||
|
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||||
|
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||||
|
units_by_id = {u.id: u for u in units}
|
||||||
|
# Build per-unit lookups from assignments
|
||||||
|
assignment_map = {a.unit_id: a for a in assignments_sorted}
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
stored_slots = _json.loads(reservation.location_slots) if reservation.location_slots else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": reservation.id,
|
"id": reservation.id,
|
||||||
@@ -235,15 +291,21 @@ async def get_reservation(
|
|||||||
"assignment_type": reservation.assignment_type,
|
"assignment_type": reservation.assignment_type,
|
||||||
"device_type": reservation.device_type,
|
"device_type": reservation.device_type,
|
||||||
"quantity_needed": reservation.quantity_needed,
|
"quantity_needed": reservation.quantity_needed,
|
||||||
|
"estimated_units": reservation.estimated_units,
|
||||||
|
"location_slots": stored_slots,
|
||||||
"notes": reservation.notes,
|
"notes": reservation.notes,
|
||||||
"color": reservation.color,
|
"color": reservation.color,
|
||||||
"assigned_units": [
|
"assigned_units": [
|
||||||
{
|
{
|
||||||
"id": u.id,
|
"id": uid,
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
|
||||||
"deployed": u.deployed
|
"deployed": units_by_id[uid].deployed if uid in units_by_id else False,
|
||||||
|
"power_type": assignment_map[uid].power_type,
|
||||||
|
"notes": assignment_map[uid].notes,
|
||||||
|
"location_name": assignment_map[uid].location_name,
|
||||||
|
"slot_index": assignment_map[uid].slot_index,
|
||||||
}
|
}
|
||||||
for u in units
|
for uid in unit_ids
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +340,11 @@ async def update_reservation(
|
|||||||
reservation.assignment_type = data["assignment_type"]
|
reservation.assignment_type = data["assignment_type"]
|
||||||
if "quantity_needed" in data:
|
if "quantity_needed" in data:
|
||||||
reservation.quantity_needed = data["quantity_needed"]
|
reservation.quantity_needed = data["quantity_needed"]
|
||||||
|
if "estimated_units" in data:
|
||||||
|
reservation.estimated_units = data["estimated_units"]
|
||||||
|
if "location_slots" in data:
|
||||||
|
import json as _json
|
||||||
|
reservation.location_slots = _json.dumps(data["location_slots"]) if data["location_slots"] is not None else None
|
||||||
if "notes" in data:
|
if "notes" in data:
|
||||||
reservation.notes = data["notes"]
|
reservation.notes = data["notes"]
|
||||||
if "color" in data:
|
if "color" in data:
|
||||||
@@ -337,52 +404,57 @@ async def assign_units_to_reservation(
|
|||||||
|
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
unit_ids = data.get("unit_ids", [])
|
unit_ids = data.get("unit_ids", [])
|
||||||
|
# Optional per-unit dicts keyed by unit_id
|
||||||
|
power_types = data.get("power_types", {})
|
||||||
|
location_notes = data.get("location_notes", {})
|
||||||
|
location_names = data.get("location_names", {})
|
||||||
|
# slot_indices: {"BE17354": 0, "BE9441": 1, ...}
|
||||||
|
slot_indices = data.get("slot_indices", {})
|
||||||
|
|
||||||
if not unit_ids:
|
# Verify units exist (allow empty list to clear all assignments)
|
||||||
raise HTTPException(status_code=400, detail="No units specified")
|
if unit_ids:
|
||||||
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
||||||
|
found_ids = {u.id for u in units}
|
||||||
|
missing = set(unit_ids) - found_ids
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
||||||
|
|
||||||
# Verify units exist
|
# Full replace: delete all existing assignments for this reservation first
|
||||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
|
||||||
found_ids = {u.id for u in units}
|
db.flush()
|
||||||
missing = set(unit_ids) - found_ids
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
|
||||||
|
|
||||||
# Check for conflicts (already assigned to overlapping reservations)
|
# Check for conflicts with other reservations and insert new assignments
|
||||||
conflicts = []
|
conflicts = []
|
||||||
for unit_id in unit_ids:
|
for unit_id in unit_ids:
|
||||||
# Check if unit is already assigned to this reservation
|
|
||||||
existing = db.query(JobReservationUnit).filter_by(
|
|
||||||
reservation_id=reservation_id,
|
|
||||||
unit_id=unit_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
continue # Already assigned, skip
|
|
||||||
|
|
||||||
# Check overlapping reservations
|
# Check overlapping reservations
|
||||||
overlapping = db.query(JobReservation).join(
|
if reservation.end_date:
|
||||||
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
overlapping = db.query(JobReservation).join(
|
||||||
).filter(
|
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
||||||
JobReservationUnit.unit_id == unit_id,
|
).filter(
|
||||||
JobReservation.id != reservation_id,
|
JobReservationUnit.unit_id == unit_id,
|
||||||
JobReservation.start_date <= reservation.end_date,
|
JobReservation.id != reservation_id,
|
||||||
JobReservation.end_date >= reservation.start_date
|
JobReservation.start_date <= reservation.end_date,
|
||||||
).first()
|
JobReservation.end_date >= reservation.start_date
|
||||||
|
).first()
|
||||||
|
|
||||||
if overlapping:
|
if overlapping:
|
||||||
conflicts.append({
|
conflicts.append({
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"conflict_reservation": overlapping.name,
|
"conflict_reservation": overlapping.name,
|
||||||
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Add assignment
|
# Add assignment
|
||||||
assignment = JobReservationUnit(
|
assignment = JobReservationUnit(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
reservation_id=reservation_id,
|
reservation_id=reservation_id,
|
||||||
unit_id=unit_id,
|
unit_id=unit_id,
|
||||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific"
|
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
|
||||||
|
power_type=power_types.get(unit_id),
|
||||||
|
notes=location_notes.get(unit_id),
|
||||||
|
location_name=location_names.get(unit_id),
|
||||||
|
slot_index=slot_indices.get(unit_id),
|
||||||
)
|
)
|
||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
|
|
||||||
@@ -511,7 +583,7 @@ async def get_reservations_list(
|
|||||||
else:
|
else:
|
||||||
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||||
|
|
||||||
# Include TBD reservations that started before window end
|
# Filter by device_type and date window
|
||||||
reservations = db.query(JobReservation).filter(
|
reservations = db.query(JobReservation).filter(
|
||||||
JobReservation.device_type == device_type,
|
JobReservation.device_type == device_type,
|
||||||
JobReservation.start_date <= end_date,
|
JobReservation.start_date <= end_date,
|
||||||
@@ -524,16 +596,38 @@ async def get_reservations_list(
|
|||||||
# Get assignment counts
|
# Get assignment counts
|
||||||
reservation_data = []
|
reservation_data = []
|
||||||
for res in reservations:
|
for res in reservations:
|
||||||
assigned_count = db.query(JobReservationUnit).filter_by(
|
assignments = db.query(JobReservationUnit).filter_by(
|
||||||
reservation_id=res.id
|
reservation_id=res.id
|
||||||
).count()
|
).all()
|
||||||
|
assigned_count = len(assignments)
|
||||||
|
|
||||||
|
# Enrich assignments with unit details, sorted by slot_index
|
||||||
|
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||||
|
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||||
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||||
|
units_by_id = {u.id: u for u in units}
|
||||||
|
assigned_units = [
|
||||||
|
{
|
||||||
|
"id": a.unit_id,
|
||||||
|
"power_type": a.power_type,
|
||||||
|
"notes": a.notes,
|
||||||
|
"location_name": a.location_name,
|
||||||
|
"slot_index": a.slot_index,
|
||||||
|
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
|
||||||
|
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
|
||||||
|
}
|
||||||
|
for a in assignments_sorted
|
||||||
|
]
|
||||||
|
|
||||||
# Check for calibration conflicts
|
# Check for calibration conflicts
|
||||||
conflicts = check_calibration_conflicts(db, res.id)
|
conflicts = check_calibration_conflicts(db, res.id)
|
||||||
|
|
||||||
|
location_count = res.quantity_needed or assigned_count
|
||||||
reservation_data.append({
|
reservation_data.append({
|
||||||
"reservation": res,
|
"reservation": res,
|
||||||
"assigned_count": assigned_count,
|
"assigned_count": assigned_count,
|
||||||
|
"location_count": location_count,
|
||||||
|
"assigned_units": assigned_units,
|
||||||
"has_conflicts": len(conflicts) > 0,
|
"has_conflicts": len(conflicts) > 0,
|
||||||
"conflict_count": len(conflicts)
|
"conflict_count": len(conflicts)
|
||||||
})
|
})
|
||||||
@@ -549,6 +643,131 @@ async def get_reservations_list(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
|
||||||
|
async def get_planner_availability(
|
||||||
|
device_type: str = "seismograph",
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
exclude_reservation_id: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get available units for the reservation planner split-panel UI.
|
||||||
|
Dates are optional — if omitted, returns all non-retired units regardless of reservations.
|
||||||
|
"""
|
||||||
|
if start_date and end_date:
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(start_date)
|
||||||
|
end = date.fromisoformat(end_date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||||
|
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
|
||||||
|
else:
|
||||||
|
# No dates: return all non-retired units of this type, with current reservation info
|
||||||
|
from backend.models import RosterUnit as RU
|
||||||
|
from datetime import timedelta
|
||||||
|
today = date.today()
|
||||||
|
all_units = db.query(RU).filter(
|
||||||
|
RU.device_type == device_type,
|
||||||
|
RU.retired == False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Build a map: unit_id -> list of active/upcoming reservations
|
||||||
|
active_assignments = db.query(JobReservationUnit).join(
|
||||||
|
JobReservation, JobReservationUnit.reservation_id == JobReservation.id
|
||||||
|
).filter(
|
||||||
|
JobReservation.device_type == device_type,
|
||||||
|
JobReservation.end_date >= today
|
||||||
|
).all()
|
||||||
|
unit_reservations = {}
|
||||||
|
for assignment in active_assignments:
|
||||||
|
res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first()
|
||||||
|
if not res:
|
||||||
|
continue
|
||||||
|
unit_reservations.setdefault(assignment.unit_id, []).append({
|
||||||
|
"reservation_id": res.id,
|
||||||
|
"reservation_name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||||
|
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||||
|
"color": res.color or "#3B82F6"
|
||||||
|
})
|
||||||
|
|
||||||
|
units = []
|
||||||
|
for u in all_units:
|
||||||
|
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||||
|
units.append({
|
||||||
|
"id": u.id,
|
||||||
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
|
"expiry_date": expiry.isoformat() if expiry else None,
|
||||||
|
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
||||||
|
"deployed": u.deployed,
|
||||||
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
|
"allocated": getattr(u, 'allocated', False) or False,
|
||||||
|
"allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "",
|
||||||
|
"note": u.note or "",
|
||||||
|
"reservations": unit_reservations.get(u.id, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort: benched first (easier to assign), then deployed, then by ID
|
||||||
|
units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"units": units,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"count": len(units)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse)
|
||||||
|
async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Return at-a-glance info for the planner quick-view modal."""
|
||||||
|
from backend.models import Emitter
|
||||||
|
u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||||
|
|
||||||
|
# Active/upcoming reservations
|
||||||
|
assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all()
|
||||||
|
reservations = []
|
||||||
|
for a in assignments:
|
||||||
|
res = db.query(JobReservation).filter(
|
||||||
|
JobReservation.id == a.reservation_id,
|
||||||
|
JobReservation.end_date >= today
|
||||||
|
).first()
|
||||||
|
if res:
|
||||||
|
reservations.append({
|
||||||
|
"name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||||
|
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||||
|
"end_date_tbd": res.end_date_tbd,
|
||||||
|
"color": res.color or "#3B82F6",
|
||||||
|
"location_name": a.location_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Last seen from emitter
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": u.id,
|
||||||
|
"unit_type": u.unit_type,
|
||||||
|
"deployed": u.deployed,
|
||||||
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
|
"note": u.note or "",
|
||||||
|
"project_id": u.project_id or "",
|
||||||
|
"address": u.address or u.location or "",
|
||||||
|
"coordinates": u.coordinates or "",
|
||||||
|
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||||
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
|
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||||
|
"cal_expired": not u.last_calibrated or (expiry and expiry < today),
|
||||||
|
"last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None,
|
||||||
|
"reservations": reservations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
||||||
async def get_available_units_partial(
|
async def get_available_units_partial(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -608,3 +827,102 @@ async def get_month_partial(
|
|||||||
"today": date.today().isoformat()
|
"today": date.today().isoformat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Promote Reservation to Project
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse)
|
||||||
|
async def promote_reservation_to_project(
|
||||||
|
reservation_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Promote a job reservation to a full project in the projects DB.
|
||||||
|
Creates: Project + MonitoringLocations + UnitAssignments.
|
||||||
|
"""
|
||||||
|
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||||
|
if not reservation:
|
||||||
|
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
project_number = data.get("project_number") or None
|
||||||
|
client_name = data.get("client_name") or None
|
||||||
|
|
||||||
|
# Map device_type to project_type_id
|
||||||
|
if reservation.device_type == "slm":
|
||||||
|
project_type_id = "sound_monitoring"
|
||||||
|
location_type = "sound"
|
||||||
|
else:
|
||||||
|
project_type_id = "vibration_monitoring"
|
||||||
|
location_type = "vibration"
|
||||||
|
|
||||||
|
# Check for duplicate project name
|
||||||
|
existing = db.query(Project).filter_by(name=reservation.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.")
|
||||||
|
|
||||||
|
# Create the project
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
project = Project(
|
||||||
|
id=project_id,
|
||||||
|
name=reservation.name,
|
||||||
|
project_number=project_number,
|
||||||
|
client_name=client_name,
|
||||||
|
project_type_id=project_type_id,
|
||||||
|
status="upcoming",
|
||||||
|
start_date=reservation.start_date,
|
||||||
|
end_date=reservation.end_date,
|
||||||
|
description=reservation.notes,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Load assignments sorted by slot_index
|
||||||
|
assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all()
|
||||||
|
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||||
|
|
||||||
|
locations_created = 0
|
||||||
|
units_assigned = 0
|
||||||
|
|
||||||
|
for i, assignment in enumerate(assignments_sorted):
|
||||||
|
loc_num = str(i + 1).zfill(3)
|
||||||
|
loc_name = assignment.location_name or f"Location {i + 1}"
|
||||||
|
|
||||||
|
location = MonitoringLocation(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=project_id,
|
||||||
|
location_type=location_type,
|
||||||
|
name=loc_name,
|
||||||
|
description=assignment.notes,
|
||||||
|
)
|
||||||
|
db.add(location)
|
||||||
|
db.flush()
|
||||||
|
locations_created += 1
|
||||||
|
|
||||||
|
if assignment.unit_id:
|
||||||
|
unit_assignment = UnitAssignment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=assignment.unit_id,
|
||||||
|
location_id=location.id,
|
||||||
|
project_id=project_id,
|
||||||
|
device_type=reservation.device_type or "seismograph",
|
||||||
|
status="active",
|
||||||
|
notes=f"Power: {assignment.power_type}" if assignment.power_type else None,
|
||||||
|
)
|
||||||
|
db.add(unit_assignment)
|
||||||
|
units_assigned += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_name": reservation.name,
|
||||||
|
"locations_created": locations_created,
|
||||||
|
"units_assigned": units_assigned,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
|
# Don't propose creating assignments at removed locations — they
|
||||||
|
# were intentionally decommissioned and shouldn't be backfill targets.
|
||||||
|
.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||||||
|
.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
|
||||||
|
# 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:
|
||||||
|
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},
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
"""
|
||||||
|
Pending deployments — field-captured "I just installed this seismograph"
|
||||||
|
records waiting to be classified into a project + location.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
POST /api/deployments/capture — capture a new pending deployment
|
||||||
|
GET /api/deployments/pending — list awaiting captures
|
||||||
|
GET /api/deployments/pending/{id} — single capture detail
|
||||||
|
POST /api/deployments/pending/{id}/promote — classify → create UnitAssignment
|
||||||
|
POST /api/deployments/pending/{id}/cancel — abandon
|
||||||
|
|
||||||
|
See backend/models.py PendingDeployment docstring for the full lifecycle.
|
||||||
|
|
||||||
|
Seismograph-only for v1; capture refuses if unit_id is anything else.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import (
|
||||||
|
PendingDeployment,
|
||||||
|
RosterUnit,
|
||||||
|
Project,
|
||||||
|
MonitoringLocation,
|
||||||
|
UnitAssignment,
|
||||||
|
UnitHistory,
|
||||||
|
)
|
||||||
|
from backend.routers.photos import extract_exif_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/deployments", tags=["pending-deployments"])
|
||||||
|
|
||||||
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_history(
|
||||||
|
db: Session,
|
||||||
|
unit_id: str,
|
||||||
|
change_type: str,
|
||||||
|
*,
|
||||||
|
old_value: Optional[str] = None,
|
||||||
|
new_value: Optional[str] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
source: str = "manual",
|
||||||
|
) -> None:
|
||||||
|
"""Mirror of project_locations._record_assignment_history — kept local
|
||||||
|
so this router doesn't depend on a project_locations import cycle."""
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type=change_type,
|
||||||
|
field_name="pending_deployment",
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
changed_at=datetime.utcnow(),
|
||||||
|
source=source,
|
||||||
|
notes=notes,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/seismograph-picker")
|
||||||
|
def seismograph_picker(
|
||||||
|
q: str = "",
|
||||||
|
limit: int = 20,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""JSON list of seismograph units for the /deploy mobile picker.
|
||||||
|
|
||||||
|
Filters out retired units. Sorts by recency of pending captures
|
||||||
|
first, then alphabetically — so units the operator is actively
|
||||||
|
deploying with surface at the top.
|
||||||
|
"""
|
||||||
|
q_clean = (q or "").strip()
|
||||||
|
qb = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.device_type == "seismograph",
|
||||||
|
RosterUnit.retired == False, # noqa: E712
|
||||||
|
)
|
||||||
|
if q_clean:
|
||||||
|
qb = qb.filter(
|
||||||
|
(RosterUnit.id.ilike(f"%{q_clean}%"))
|
||||||
|
| (RosterUnit.note.ilike(f"%{q_clean}%"))
|
||||||
|
)
|
||||||
|
units = qb.order_by(RosterUnit.id).limit(limit).all()
|
||||||
|
|
||||||
|
# Annotate with "has an awaiting pending deployment" so the picker
|
||||||
|
# can de-emphasize / warn on units that are already mid-deploy.
|
||||||
|
pending_unit_ids = {
|
||||||
|
r[0] for r in db.query(PendingDeployment.unit_id)
|
||||||
|
.filter_by(status="awaiting").distinct().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"id": u.id,
|
||||||
|
"note": u.note,
|
||||||
|
"deployed": u.deployed,
|
||||||
|
"has_pending": u.id in pending_unit_ids,
|
||||||
|
}
|
||||||
|
for u in units
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/capture")
|
||||||
|
async def capture_deployment(
|
||||||
|
unit_id: str = Form(...),
|
||||||
|
operator_note: str = Form(""),
|
||||||
|
captured_at_iso: str = Form(""),
|
||||||
|
photo: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Field-capture endpoint.
|
||||||
|
|
||||||
|
Multipart form:
|
||||||
|
unit_id — seismograph being deployed
|
||||||
|
operator_note — optional free-text site memo
|
||||||
|
captured_at_iso — optional override of the capture timestamp
|
||||||
|
(default: photo's EXIF DateTimeOriginal, or now)
|
||||||
|
photo — install photo (EXIF GPS extracted if present)
|
||||||
|
|
||||||
|
Refuses if unit_id isn't a seismograph (SLM deployments don't follow
|
||||||
|
the same field-install pattern).
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id!r} not found.")
|
||||||
|
if unit.device_type != "seismograph":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Pending deployments are for seismographs only "
|
||||||
|
f"(this unit is {unit.device_type}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate + save the photo.
|
||||||
|
file_ext = Path(photo.filename or "photo.jpg").suffix.lower()
|
||||||
|
if file_ext not in _ALLOWED_PHOTO_EXTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid photo type {file_ext!r}. Allowed: {sorted(_ALLOWED_PHOTO_EXTS)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||||
|
unit_photo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
capture_id = str(uuid.uuid4())
|
||||||
|
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"install_{ts_str}_{capture_id[:8]}{file_ext}"
|
||||||
|
file_path = unit_photo_dir / filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "wb") as buf:
|
||||||
|
shutil.copyfileobj(photo.file, buf)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save photo: {e}")
|
||||||
|
|
||||||
|
# Extract EXIF — best-effort. No EXIF / no GPS is fine; operator
|
||||||
|
# can fill coordinates manually later in the promote step.
|
||||||
|
metadata = extract_exif_data(file_path)
|
||||||
|
coords = metadata.get("coordinates") # "lat,lon" or None
|
||||||
|
photo_ts = metadata.get("timestamp") # datetime or None
|
||||||
|
|
||||||
|
if captured_at_iso:
|
||||||
|
try:
|
||||||
|
captured_at = datetime.fromisoformat(captured_at_iso)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid captured_at_iso: {captured_at_iso!r}")
|
||||||
|
elif photo_ts:
|
||||||
|
captured_at = photo_ts
|
||||||
|
else:
|
||||||
|
captured_at = datetime.utcnow()
|
||||||
|
|
||||||
|
pd = PendingDeployment(
|
||||||
|
id = capture_id,
|
||||||
|
unit_id = unit_id,
|
||||||
|
captured_at = captured_at,
|
||||||
|
coordinates = coords,
|
||||||
|
operator_note = (operator_note or "").strip() or None,
|
||||||
|
photo_filename = filename,
|
||||||
|
status = "awaiting",
|
||||||
|
)
|
||||||
|
db.add(pd)
|
||||||
|
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=unit_id,
|
||||||
|
change_type="pending_deployment_captured",
|
||||||
|
new_value=f"awaiting classification @ {captured_at:%Y-%m-%d %H:%M}"
|
||||||
|
+ (f" • {coords}" if coords else ""),
|
||||||
|
notes=(operator_note or None),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pd)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"pending_deployment": _to_dict(pd, unit=unit),
|
||||||
|
"photo_url": f"/api/unit/{unit_id}/photo/{filename}",
|
||||||
|
"extracted_coords": coords,
|
||||||
|
"extracted_timestamp": photo_ts.isoformat() if photo_ts else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
def list_pending(
|
||||||
|
status: str = "awaiting",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List pending deployments by status (default: awaiting classification)."""
|
||||||
|
rows = (
|
||||||
|
db.query(PendingDeployment)
|
||||||
|
.filter_by(status=status)
|
||||||
|
.order_by(PendingDeployment.captured_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# Bulk-resolve unit references in one query (avoid N+1).
|
||||||
|
unit_ids = {r.unit_id for r in rows}
|
||||||
|
units = {u.id: u for u in db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()} \
|
||||||
|
if unit_ids else {}
|
||||||
|
return {
|
||||||
|
"count": len(rows),
|
||||||
|
"status": status,
|
||||||
|
"pending_deployments": [_to_dict(r, unit=units.get(r.unit_id)) for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending/{pending_id}")
|
||||||
|
def get_pending(pending_id: str, db: Session = Depends(get_db)):
|
||||||
|
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
|
||||||
|
if not pd:
|
||||||
|
raise HTTPException(status_code=404, detail="Pending deployment not found.")
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
|
||||||
|
return _to_dict(pd, unit=unit, detail=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/{pending_id}/promote")
|
||||||
|
async def promote_pending(
|
||||||
|
pending_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Classify a pending deployment → create a UnitAssignment.
|
||||||
|
|
||||||
|
Body JSON — one of two shapes:
|
||||||
|
|
||||||
|
1. Assign to existing location:
|
||||||
|
{
|
||||||
|
"location_id": "<uuid>",
|
||||||
|
"notes": "<optional>"
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Create a new location under (existing or new) project:
|
||||||
|
{
|
||||||
|
"project_id": "<existing>" | null, # null means create new
|
||||||
|
"project_name": "<required if project_id is null>",
|
||||||
|
"project_type_id": "<existing project_type id, e.g. 'vibration_monitoring'>",
|
||||||
|
# required if creating new project
|
||||||
|
"location_name": "<required>",
|
||||||
|
"use_captured_coords": true | false, # default true — write the
|
||||||
|
# pending's coordinates onto
|
||||||
|
# the new location
|
||||||
|
"notes": "<optional>"
|
||||||
|
}
|
||||||
|
|
||||||
|
Status flips to "assigned"; resulting_assignment_id is populated.
|
||||||
|
"""
|
||||||
|
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
|
||||||
|
if not pd:
|
||||||
|
raise HTTPException(status_code=404, detail="Pending deployment not found.")
|
||||||
|
if pd.status != "awaiting":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Pending deployment is {pd.status!r}, not awaiting — already classified?",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body.")
|
||||||
|
|
||||||
|
notes = (payload.get("notes") or "").strip() or None
|
||||||
|
|
||||||
|
# Resolve / create the location.
|
||||||
|
location_id = payload.get("location_id")
|
||||||
|
if location_id:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.")
|
||||||
|
project_id = location.project_id
|
||||||
|
else:
|
||||||
|
# Create-new path. Need a project (existing or new).
|
||||||
|
project_id = payload.get("project_id")
|
||||||
|
if not project_id:
|
||||||
|
project_name = (payload.get("project_name") or "").strip()
|
||||||
|
project_type_id = (payload.get("project_type_id") or "").strip()
|
||||||
|
if not project_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Either project_id, or project_name + project_type_id, required.",
|
||||||
|
)
|
||||||
|
if not project_type_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="project_type_id required when creating a new project.",
|
||||||
|
)
|
||||||
|
new_project = Project(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=project_name,
|
||||||
|
project_type_id=project_type_id,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(new_project)
|
||||||
|
db.flush()
|
||||||
|
project_id = new_project.id
|
||||||
|
|
||||||
|
loc_name = (payload.get("location_name") or "").strip()
|
||||||
|
if not loc_name:
|
||||||
|
raise HTTPException(status_code=400, detail="location_name required.")
|
||||||
|
use_coords = payload.get("use_captured_coords", True)
|
||||||
|
location = MonitoringLocation(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=project_id,
|
||||||
|
location_type="vibration", # seismographs only
|
||||||
|
name=loc_name,
|
||||||
|
coordinates=(pd.coordinates if use_coords else None),
|
||||||
|
)
|
||||||
|
db.add(location)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# If this location already has an active assignment, the /deploy
|
||||||
|
# capture means someone replaced that unit in the field — close the
|
||||||
|
# old assignment, break the outgoing unit's modem pairing, and bench
|
||||||
|
# it so the heartbeat / polling subsystem stops chasing it.
|
||||||
|
existing_active = db.query(UnitAssignment).filter(
|
||||||
|
UnitAssignment.location_id == location.id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
).first()
|
||||||
|
if existing_active and existing_active.unit_id != pd.unit_id:
|
||||||
|
existing_active.assigned_until = pd.captured_at
|
||||||
|
existing_active.status = "completed"
|
||||||
|
old_unit = db.query(RosterUnit).filter_by(id=existing_active.unit_id).first()
|
||||||
|
if old_unit:
|
||||||
|
if old_unit.deployed_with_modem_id:
|
||||||
|
old_modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=old_unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if old_modem and old_modem.deployed_with_unit_id == old_unit.id:
|
||||||
|
old_modem.deployed_with_unit_id = None
|
||||||
|
old_unit.deployed_with_modem_id = None
|
||||||
|
if old_unit.deployed:
|
||||||
|
old_unit.deployed = False
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=existing_active.unit_id,
|
||||||
|
change_type="assignment_swapped",
|
||||||
|
old_value=location.name,
|
||||||
|
new_value=f"superseded by /deploy capture → {pd.unit_id}",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the assignment. assigned_at = pending capture time (so
|
||||||
|
# events emitted after the install are correctly attributed back
|
||||||
|
# to this location).
|
||||||
|
assignment = UnitAssignment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=pd.unit_id,
|
||||||
|
location_id=location.id,
|
||||||
|
project_id=project_id,
|
||||||
|
device_type="seismograph",
|
||||||
|
assigned_at=pd.captured_at,
|
||||||
|
assigned_until=None,
|
||||||
|
status="active",
|
||||||
|
notes=notes,
|
||||||
|
source="manual",
|
||||||
|
)
|
||||||
|
db.add(assignment)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Incoming unit is in the field again — flip it back to deployed
|
||||||
|
# if it was on the bench (mirrors the swap endpoint).
|
||||||
|
incoming_unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
|
||||||
|
if incoming_unit and not incoming_unit.deployed:
|
||||||
|
incoming_unit.deployed = True
|
||||||
|
|
||||||
|
# Promote the pending row.
|
||||||
|
pd.status = "assigned"
|
||||||
|
pd.promoted_at = datetime.utcnow()
|
||||||
|
pd.resulting_assignment_id = assignment.id
|
||||||
|
pd.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=pd.unit_id,
|
||||||
|
change_type="pending_deployment_promoted",
|
||||||
|
old_value="awaiting",
|
||||||
|
new_value=f"{location.name} (assignment {assignment.id[:8]})",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pd)
|
||||||
|
db.refresh(assignment)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"assignment_id": assignment.id,
|
||||||
|
"location_id": location.id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"promoted_at": pd.promoted_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/{pending_id}/cancel")
|
||||||
|
async def cancel_pending(
|
||||||
|
pending_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Mark a pending deployment as cancelled (operator captured by mistake)."""
|
||||||
|
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
|
||||||
|
if not pd:
|
||||||
|
raise HTTPException(status_code=404, detail="Pending deployment not found.")
|
||||||
|
if pd.status != "awaiting":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Pending deployment is {pd.status!r}, not awaiting.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
reason = (payload.get("reason") or "").strip() or None
|
||||||
|
|
||||||
|
pd.status = "cancelled"
|
||||||
|
pd.cancelled_at = datetime.utcnow()
|
||||||
|
pd.cancelled_reason = reason
|
||||||
|
pd.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=pd.unit_id,
|
||||||
|
change_type="pending_deployment_cancelled",
|
||||||
|
old_value="awaiting",
|
||||||
|
new_value="cancelled",
|
||||||
|
notes=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict:
|
||||||
|
out = {
|
||||||
|
"id": pd.id,
|
||||||
|
"unit_id": pd.unit_id,
|
||||||
|
"captured_at": pd.captured_at.isoformat() if pd.captured_at else None,
|
||||||
|
"coordinates": pd.coordinates,
|
||||||
|
"operator_note": pd.operator_note,
|
||||||
|
"photo_filename": pd.photo_filename,
|
||||||
|
"photo_url": f"/api/unit/{pd.unit_id}/photo/{pd.photo_filename}"
|
||||||
|
if pd.photo_filename else None,
|
||||||
|
"status": pd.status,
|
||||||
|
"created_at": pd.created_at.isoformat() if pd.created_at else None,
|
||||||
|
}
|
||||||
|
if pd.status == "assigned":
|
||||||
|
out["promoted_at"] = pd.promoted_at.isoformat() if pd.promoted_at else None
|
||||||
|
out["resulting_assignment_id"] = pd.resulting_assignment_id
|
||||||
|
if pd.status == "cancelled":
|
||||||
|
out["cancelled_at"] = pd.cancelled_at.isoformat() if pd.cancelled_at else None
|
||||||
|
out["cancelled_reason"] = pd.cancelled_reason
|
||||||
|
|
||||||
|
if unit:
|
||||||
|
out["unit"] = {
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type,
|
||||||
|
"note": unit.note,
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
}
|
||||||
|
return out
|
||||||
File diff suppressed because it is too large
Load Diff
+672
-32
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@ import httpx
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||||
|
import uuid
|
||||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
@@ -27,6 +28,38 @@ def get_calibration_interval(db: Session) -> int:
|
|||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_deployment_record(db: Session, unit: RosterUnit, new_deployed: bool):
|
||||||
|
"""
|
||||||
|
Keep DeploymentRecord in sync with the deployed flag.
|
||||||
|
|
||||||
|
deployed True → open a new DeploymentRecord if none is already open.
|
||||||
|
deployed False → close the active DeploymentRecord by setting actual_removal_date = today.
|
||||||
|
"""
|
||||||
|
if new_deployed:
|
||||||
|
existing = db.query(DeploymentRecord).filter(
|
||||||
|
DeploymentRecord.unit_id == unit.id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
record = DeploymentRecord(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=unit.id,
|
||||||
|
project_ref=unit.project_id or None,
|
||||||
|
deployed_date=date.today(),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
else:
|
||||||
|
active = db.query(DeploymentRecord).filter(
|
||||||
|
DeploymentRecord.unit_id == unit.id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).first()
|
||||||
|
if active:
|
||||||
|
active.actual_removal_date = date.today()
|
||||||
|
active.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||||
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||||
"""Helper function to record a change in unit history"""
|
"""Helper function to record a change in unit history"""
|
||||||
@@ -146,6 +179,7 @@ async def add_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -177,6 +211,7 @@ async def add_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||||
|
|
||||||
# Convert port strings to integers
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
@@ -211,6 +246,7 @@ async def add_roster_unit(
|
|||||||
unit_type=unit_type,
|
unit_type=unit_type,
|
||||||
deployed=deployed_bool,
|
deployed=deployed_bool,
|
||||||
retired=retired_bool,
|
retired=retired_bool,
|
||||||
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -463,6 +499,9 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
|
"out_for_calibration": unit.out_for_calibration or False,
|
||||||
|
"allocated": getattr(unit, 'allocated', False) or False,
|
||||||
|
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"location": unit.location or "",
|
||||||
@@ -494,6 +533,9 @@ async def edit_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
|
allocated: str = Form(None),
|
||||||
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -535,6 +577,8 @@ async def edit_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||||
|
allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False
|
||||||
|
|
||||||
# Convert port strings to integers
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
@@ -564,12 +608,16 @@ async def edit_roster_unit(
|
|||||||
old_note = unit.note
|
old_note = unit.note
|
||||||
old_deployed = unit.deployed
|
old_deployed = unit.deployed
|
||||||
old_retired = unit.retired
|
old_retired = unit.retired
|
||||||
|
old_out_for_calibration = unit.out_for_calibration
|
||||||
|
|
||||||
# Update all fields
|
# Update all fields
|
||||||
unit.device_type = device_type
|
unit.device_type = device_type
|
||||||
unit.unit_type = unit_type
|
unit.unit_type = unit_type
|
||||||
unit.deployed = deployed_bool
|
unit.deployed = deployed_bool
|
||||||
unit.retired = retired_bool
|
unit.retired = retired_bool
|
||||||
|
unit.out_for_calibration = out_for_calibration_bool
|
||||||
|
unit.allocated = allocated_bool
|
||||||
|
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
unit.location = location
|
||||||
@@ -671,12 +719,18 @@ async def edit_roster_unit(
|
|||||||
status_text = "deployed" if deployed else "benched"
|
status_text = "deployed" if deployed else "benched"
|
||||||
old_status_text = "deployed" if old_deployed else "benched"
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||||
|
sync_deployment_record(db, unit, deployed_bool)
|
||||||
|
|
||||||
if old_retired != retired:
|
if old_retired != retired:
|
||||||
status_text = "retired" if retired else "active"
|
status_text = "retired" if retired else "active"
|
||||||
old_status_text = "retired" if old_retired else "active"
|
old_status_text = "retired" if old_retired else "active"
|
||||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
if old_out_for_calibration != out_for_calibration_bool:
|
||||||
|
status_text = "out_for_calibration" if out_for_calibration_bool else "available"
|
||||||
|
old_status_text = "out_for_calibration" if old_out_for_calibration else "available"
|
||||||
|
record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
# Handle cascade to paired device
|
# Handle cascade to paired device
|
||||||
cascaded_unit_id = None
|
cascaded_unit_id = None
|
||||||
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
||||||
@@ -782,6 +836,7 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D
|
|||||||
new_value=status_text,
|
new_value=status_text,
|
||||||
source="manual"
|
source="manual"
|
||||||
)
|
)
|
||||||
|
sync_deployment_record(db, unit, deployed)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ Seismograph Dashboard API Router
|
|||||||
Provides endpoints for the seismograph-specific dashboard
|
Provides endpoints for the seismograph-specific dashboard
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit, UnitHistory, UserPreferences
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||||
@@ -28,7 +28,8 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
total = len(seismos)
|
total = len(seismos)
|
||||||
deployed = sum(1 for s in seismos if s.deployed)
|
deployed = sum(1 for s in seismos if s.deployed)
|
||||||
benched = sum(1 for s in seismos if not s.deployed)
|
benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration)
|
||||||
|
out_for_calibration = sum(1 for s in seismos if s.out_for_calibration)
|
||||||
|
|
||||||
# Count modems assigned to deployed seismographs
|
# Count modems assigned to deployed seismographs
|
||||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||||
@@ -41,6 +42,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
"total": total,
|
"total": total,
|
||||||
"deployed": deployed,
|
"deployed": deployed,
|
||||||
"benched": benched,
|
"benched": benched,
|
||||||
|
"out_for_calibration": out_for_calibration,
|
||||||
"with_modem": with_modem,
|
"with_modem": with_modem,
|
||||||
"without_modem": without_modem
|
"without_modem": without_modem
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,9 @@ async def get_seismo_units(
|
|||||||
if status == "deployed":
|
if status == "deployed":
|
||||||
query = query.filter(RosterUnit.deployed == True)
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
elif status == "benched":
|
elif status == "benched":
|
||||||
query = query.filter(RosterUnit.deployed == False)
|
query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False)
|
||||||
|
elif status == "out_for_calibration":
|
||||||
|
query = query.filter(RosterUnit.out_for_calibration == True)
|
||||||
|
|
||||||
# Apply modem filter
|
# Apply modem filter
|
||||||
if modem == "with":
|
if modem == "with":
|
||||||
@@ -116,3 +120,109 @@ async def get_seismo_units(
|
|||||||
"today": date.today()
|
"today": date.today()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_calibration_interval(db: Session) -> int:
|
||||||
|
prefs = db.query(UserPreferences).first()
|
||||||
|
if prefs and prefs.calibration_interval_days:
|
||||||
|
return prefs.calibration_interval_days
|
||||||
|
return 365
|
||||||
|
|
||||||
|
|
||||||
|
def _row_context(request: Request, unit: RosterUnit) -> dict:
|
||||||
|
return {"request": request, "unit": unit, "today": date.today()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse)
|
||||||
|
async def quick_update_seismo_unit(
|
||||||
|
unit_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
status: str = Form(...),
|
||||||
|
last_calibrated: str = Form(""),
|
||||||
|
note: str = Form(""),
|
||||||
|
):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
# --- Status ---
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
old_out_for_cal = unit.out_for_calibration
|
||||||
|
if status == "deployed":
|
||||||
|
unit.deployed = True
|
||||||
|
unit.out_for_calibration = False
|
||||||
|
elif status == "out_for_calibration":
|
||||||
|
unit.deployed = False
|
||||||
|
unit.out_for_calibration = True
|
||||||
|
else:
|
||||||
|
unit.deployed = False
|
||||||
|
unit.out_for_calibration = False
|
||||||
|
|
||||||
|
if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal:
|
||||||
|
old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched")
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="deployed_change",
|
||||||
|
field_name="status",
|
||||||
|
old_value=old_status,
|
||||||
|
new_value=status,
|
||||||
|
source="manual",
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- Last calibrated ---
|
||||||
|
old_cal = unit.last_calibrated
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||||
|
unit.last_calibrated = new_cal
|
||||||
|
unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db))
|
||||||
|
else:
|
||||||
|
unit.last_calibrated = None
|
||||||
|
unit.next_calibration_due = None
|
||||||
|
|
||||||
|
if unit.last_calibrated != old_cal:
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="calibration_status_change",
|
||||||
|
field_name="last_calibrated",
|
||||||
|
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
|
||||||
|
new_value=last_calibrated or None,
|
||||||
|
source="manual",
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- Note ---
|
||||||
|
old_note = unit.note
|
||||||
|
unit.note = note or None
|
||||||
|
if unit.note != old_note:
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="note_change",
|
||||||
|
field_name="note",
|
||||||
|
old_value=old_note,
|
||||||
|
new_value=unit.note,
|
||||||
|
source="manual",
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(unit)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
SFM (Seismograph Field Module) Proxy Router
|
||||||
|
|
||||||
|
Proxies requests from terra-view to the standalone SFM backend service.
|
||||||
|
SFM runs on port 8200 and handles MiniMate Plus seismograph communication
|
||||||
|
and event database queries.
|
||||||
|
|
||||||
|
SFM endpoints are at root level (e.g. /db/units, /device/info) — no /api/ prefix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/sfm", tags=["sfm"])
|
||||||
|
|
||||||
|
# SFM backend URL - configurable via environment variable
|
||||||
|
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def check_sfm_health():
|
||||||
|
"""
|
||||||
|
Check if the SFM backend service is reachable and healthy.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(f"{SFM_BASE_URL}/health")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"sfm_status": "connected",
|
||||||
|
"sfm_url": SFM_BASE_URL,
|
||||||
|
"sfm_response": data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "degraded",
|
||||||
|
"sfm_status": "error",
|
||||||
|
"sfm_url": SFM_BASE_URL,
|
||||||
|
"detail": f"SFM returned status {response.status_code}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"sfm_status": "unreachable",
|
||||||
|
"sfm_url": SFM_BASE_URL,
|
||||||
|
"detail": "Cannot connect to SFM backend. Is it running?"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"sfm_status": "error",
|
||||||
|
"sfm_url": SFM_BASE_URL,
|
||||||
|
"detail": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP catch-all — proxies everything else to SFM backend
|
||||||
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
|
async def proxy_to_sfm(path: str, request: Request):
|
||||||
|
"""
|
||||||
|
Proxy all requests to the SFM backend service.
|
||||||
|
|
||||||
|
SFM endpoints have no /api/ prefix — target URL is {SFM_BASE_URL}/{path}.
|
||||||
|
Timeout is 60s to allow for live device round-trips (event downloads can
|
||||||
|
take 30-45s for a full event list).
|
||||||
|
"""
|
||||||
|
# Build target URL — SFM endpoints live at root, not /api/
|
||||||
|
target_url = f"{SFM_BASE_URL}/{path}"
|
||||||
|
|
||||||
|
# Forward query params
|
||||||
|
query_params = dict(request.query_params)
|
||||||
|
|
||||||
|
# Read body for mutation requests
|
||||||
|
body = None
|
||||||
|
if request.method in ["POST", "PUT", "PATCH"]:
|
||||||
|
try:
|
||||||
|
body = await request.body()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read request body: {e}")
|
||||||
|
body = None
|
||||||
|
|
||||||
|
# Strip hop-by-hop headers
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||||
|
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||||
|
|
||||||
|
logger.info(f"Proxying {request.method} {path} → SFM: {target_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
params=query_params,
|
||||||
|
headers=proxy_headers,
|
||||||
|
content=body
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
media_type=response.headers.get("content-type")
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(f"Failed to connect to SFM backend at {SFM_BASE_URL}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"SFM backend service unavailable. Is SFM running on {SFM_BASE_URL}?"
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(f"Timeout connecting to SFM backend at {SFM_BASE_URL}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail="SFM backend timeout"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error proxying to SFM: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to proxy request to SFM: {str(e)}"
|
||||||
|
)
|
||||||
+100
-2
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
@@ -72,3 +72,101 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"slm_serial_number": unit.slm_serial_number,
|
"slm_serial_number": unit.slm_serial_number,
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id
|
"deployed_with_modem_id": unit.deployed_with_modem_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units/{unit_id}/events")
|
||||||
|
async def get_unit_events(
|
||||||
|
unit_id: str,
|
||||||
|
bucket: str = Query("all", regex="^(all|attributed|unattributed)$"),
|
||||||
|
from_dt: Optional[datetime] = Query(None),
|
||||||
|
to_dt: Optional[datetime] = Query(None),
|
||||||
|
false_trigger: Optional[bool] = Query(None),
|
||||||
|
limit: int = Query(500, ge=1, le=5000),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return SFM events for a single unit, annotated with assignment attribution.
|
||||||
|
|
||||||
|
Each event includes an `attribution` object pointing at the project/location
|
||||||
|
it falls into (or null if outside every assignment window). Unattributed
|
||||||
|
events also carry a `nearest_assignment` field with `delta_days` so the
|
||||||
|
operator can see how far off the nearest assignment is — useful for
|
||||||
|
deciding whether to backdate the assignment to absorb the event.
|
||||||
|
|
||||||
|
Bucket filter:
|
||||||
|
- all (default): every event
|
||||||
|
- attributed: only events inside an assignment window
|
||||||
|
- unattributed: only orphan events (the diagnostic bucket)
|
||||||
|
|
||||||
|
Non-seismograph units return an empty events list. The route does not
|
||||||
|
404 for SLMs/modems so the unit detail page can render the section
|
||||||
|
conditionally without depending on the response shape.
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
if unit.device_type != "seismograph":
|
||||||
|
return {
|
||||||
|
"events": [],
|
||||||
|
"count": 0,
|
||||||
|
"stats": {
|
||||||
|
"event_count": 0,
|
||||||
|
"unattributed_count": 0,
|
||||||
|
"peak_pvs": None,
|
||||||
|
"peak_pvs_at": None,
|
||||||
|
"peak_pvs_serial": None,
|
||||||
|
"last_event": None,
|
||||||
|
"false_trigger_count": 0,
|
||||||
|
},
|
||||||
|
"assignments_total": 0,
|
||||||
|
"device_type": unit.device_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
from backend.services.sfm_events import events_for_unit
|
||||||
|
|
||||||
|
result = await events_for_unit(
|
||||||
|
db,
|
||||||
|
unit_id,
|
||||||
|
bucket=bucket,
|
||||||
|
from_dt=from_dt,
|
||||||
|
to_dt=to_dt,
|
||||||
|
false_trigger=false_trigger,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
result["device_type"] = unit.device_type
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units/{unit_id}/deployment_timeline")
|
||||||
|
async def get_unit_deployment_timeline(
|
||||||
|
unit_id: str,
|
||||||
|
include_events: bool = Query(True),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return a chronological deployment timeline for a unit.
|
||||||
|
|
||||||
|
Merges three sources:
|
||||||
|
1. unit_assignments — authoritative project/location deployments
|
||||||
|
2. unit_history — state changes (calibration, retirement, etc.)
|
||||||
|
3. SFM events — per-assignment overlay (count + peak PVS + last event)
|
||||||
|
|
||||||
|
Replaces the legacy /api/deployments/{unit_id} (which read the
|
||||||
|
deprecated `deployment_records` table) and the
|
||||||
|
/api/roster/history/{unit_id} timeline endpoint, unifying them into
|
||||||
|
a single derived view.
|
||||||
|
|
||||||
|
Gaps >= 1 day between consecutive assignments are surfaced as
|
||||||
|
synthetic "gap" entries.
|
||||||
|
|
||||||
|
Pass include_events=false to skip the SFM event overlay (saves N
|
||||||
|
HTTP calls; useful for fast text-only history dumps).
|
||||||
|
"""
|
||||||
|
from backend.services.deployment_timeline import deployment_timeline_for_unit
|
||||||
|
|
||||||
|
return await deployment_timeline_for_unit(
|
||||||
|
db,
|
||||||
|
unit_id,
|
||||||
|
include_event_overlay=include_events,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Watcher Manager — admin API for series3-watcher and thor-watcher agents.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /api/admin/watchers — list all watcher agents
|
||||||
|
GET /api/admin/watchers/{agent_id} — get single agent detail
|
||||||
|
POST /api/admin/watchers/{agent_id}/trigger-update — flag agent for update
|
||||||
|
POST /api/admin/watchers/{agent_id}/clear-update — clear update flag
|
||||||
|
GET /api/admin/watchers/{agent_id}/update-check — polled by watcher on heartbeat
|
||||||
|
|
||||||
|
Page:
|
||||||
|
GET /admin/watchers — HTML admin page
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import WatcherAgent
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter(tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _agent_to_dict(agent: WatcherAgent) -> dict:
|
||||||
|
last_seen = agent.last_seen
|
||||||
|
if last_seen:
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
age_minutes = int((now_utc - last_seen).total_seconds() // 60)
|
||||||
|
if age_minutes > 60:
|
||||||
|
status = "missing"
|
||||||
|
else:
|
||||||
|
status = "ok"
|
||||||
|
else:
|
||||||
|
age_minutes = None
|
||||||
|
status = "missing"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": agent.id,
|
||||||
|
"source_type": agent.source_type,
|
||||||
|
"version": agent.version,
|
||||||
|
"last_seen": last_seen.isoformat() if last_seen else None,
|
||||||
|
"age_minutes": age_minutes,
|
||||||
|
"status": status,
|
||||||
|
"ip_address": agent.ip_address,
|
||||||
|
"log_tail": agent.log_tail,
|
||||||
|
"update_pending": bool(agent.update_pending),
|
||||||
|
"update_version": agent.update_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── API routes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/api/admin/watchers")
|
||||||
|
def list_watchers(db: Session = Depends(get_db)):
|
||||||
|
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
|
||||||
|
return [_agent_to_dict(a) for a in agents]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/watchers/{agent_id}")
|
||||||
|
def get_watcher(agent_id: str, db: Session = Depends(get_db)):
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Watcher agent not found")
|
||||||
|
return _agent_to_dict(agent)
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerUpdateRequest(BaseModel):
|
||||||
|
version: Optional[str] = None # target version label (informational)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/watchers/{agent_id}/trigger-update")
|
||||||
|
def trigger_update(agent_id: str, body: TriggerUpdateRequest, db: Session = Depends(get_db)):
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Watcher agent not found")
|
||||||
|
agent.update_pending = True
|
||||||
|
agent.update_version = body.version
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "agent_id": agent_id, "update_pending": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/watchers/{agent_id}/clear-update")
|
||||||
|
def clear_update(agent_id: str, db: Session = Depends(get_db)):
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Watcher agent not found")
|
||||||
|
agent.update_pending = False
|
||||||
|
agent.update_version = None
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "agent_id": agent_id, "update_pending": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/watchers/{agent_id}/update-check")
|
||||||
|
def update_check(agent_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Polled by watcher agents on each heartbeat cycle.
|
||||||
|
Returns update_available=True when an update has been triggered via the UI.
|
||||||
|
Automatically clears the flag after the watcher acknowledges it.
|
||||||
|
"""
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
return {"update_available": False}
|
||||||
|
|
||||||
|
pending = bool(agent.update_pending)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
# Clear the flag — the watcher will now self-update
|
||||||
|
agent.update_pending = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"update_available": pending,
|
||||||
|
"version": agent.update_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/admin/watchers", response_class=HTMLResponse)
|
||||||
|
def admin_watchers_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
|
||||||
|
agents_data = [_agent_to_dict(a) for a in agents]
|
||||||
|
return templates.TemplateResponse("admin_watchers.html", {
|
||||||
|
"request": request,
|
||||||
|
"agents": agents_data,
|
||||||
|
})
|
||||||
+72
-4
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import Emitter
|
from backend.models import Emitter, WatcherAgent
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -107,6 +107,35 @@ def get_fleet_status(db: Session = Depends(get_db)):
|
|||||||
emitters = db.query(Emitter).all()
|
emitters = db.query(Emitter).all()
|
||||||
return emitters
|
return emitters
|
||||||
|
|
||||||
|
# ── Watcher agent upsert helper ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _upsert_watcher_agent(db: Session, source_id: str, source_type: str,
|
||||||
|
version: str, ip_address: str, log_tail: str,
|
||||||
|
status: str) -> None:
|
||||||
|
"""Create or update the WatcherAgent row for a given source_id."""
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first()
|
||||||
|
if agent:
|
||||||
|
agent.source_type = source_type
|
||||||
|
agent.version = version
|
||||||
|
agent.last_seen = datetime.utcnow()
|
||||||
|
agent.status = status
|
||||||
|
if ip_address:
|
||||||
|
agent.ip_address = ip_address
|
||||||
|
if log_tail is not None:
|
||||||
|
agent.log_tail = log_tail
|
||||||
|
else:
|
||||||
|
agent = WatcherAgent(
|
||||||
|
id=source_id,
|
||||||
|
source_type=source_type,
|
||||||
|
version=version,
|
||||||
|
last_seen=datetime.utcnow(),
|
||||||
|
status=status,
|
||||||
|
ip_address=ip_address,
|
||||||
|
log_tail=log_tail,
|
||||||
|
)
|
||||||
|
db.add(agent)
|
||||||
|
|
||||||
|
|
||||||
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
@@ -120,6 +149,11 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
source = payload.get("source_id")
|
source = payload.get("source_id")
|
||||||
units = payload.get("units", [])
|
units = payload.get("units", [])
|
||||||
|
version = payload.get("version")
|
||||||
|
log_tail = payload.get("log_tail") # list of strings or None
|
||||||
|
import json as _json
|
||||||
|
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
print("\n=== Series 3 Heartbeat ===")
|
print("\n=== Series 3 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -182,13 +216,27 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
if source:
|
||||||
|
_upsert_watcher_agent(db, source, "series3_watcher", version,
|
||||||
|
client_ip, log_tail_str, "ok")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Check if an update has been triggered for this agent
|
||||||
|
update_available = False
|
||||||
|
if source:
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
|
||||||
|
if agent and agent.update_pending:
|
||||||
|
update_available = True
|
||||||
|
agent.update_pending = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": results
|
"results": results,
|
||||||
|
"update_available": update_available,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -219,8 +267,14 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
"""
|
"""
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
|
|
||||||
source = payload.get("source", "series4_emitter")
|
# Accept source_id (new standard field) with fallback to legacy "source" key
|
||||||
|
source = payload.get("source_id") or payload.get("source", "series4_emitter")
|
||||||
units = payload.get("units", [])
|
units = payload.get("units", [])
|
||||||
|
version = payload.get("version")
|
||||||
|
log_tail = payload.get("log_tail")
|
||||||
|
import json as _json
|
||||||
|
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
print("\n=== Series 4 Heartbeat ===")
|
print("\n=== Series 4 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -276,11 +330,25 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
if source:
|
||||||
|
_upsert_watcher_agent(db, source, "series4_watcher", version,
|
||||||
|
client_ip, log_tail_str, "ok")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Check if an update has been triggered for this agent
|
||||||
|
update_available = False
|
||||||
|
if source:
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
|
||||||
|
if agent and agent.update_pending:
|
||||||
|
update_available = True
|
||||||
|
agent.update_pending = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": results
|
"results": results,
|
||||||
|
"update_available": update_available,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""
|
||||||
|
Deployment-history calendar service — builds the data structure for the
|
||||||
|
fleet-wide deployment-history grid (`/tools/deployment-history`).
|
||||||
|
|
||||||
|
For each calendar day in a 12-month window, computes which projects had
|
||||||
|
at least one unit assigned to a location on that day. Renders as
|
||||||
|
multi-month grid (job-planner style) with project-colored bars per day.
|
||||||
|
|
||||||
|
Distinct from `services/fleet_calendar_service.py` which renders
|
||||||
|
forward-looking RESERVATIONS for the planner. This one is purely
|
||||||
|
historical / current — it walks `unit_assignments` instead of
|
||||||
|
`job_reservations`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from calendar import monthrange
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from backend.models import Project, UnitAssignment
|
||||||
|
|
||||||
|
|
||||||
|
# Color palette for projects without an explicit color attribute. Chosen
|
||||||
|
# to have decent contrast on both light and dark backgrounds; cycles
|
||||||
|
# deterministically by SHA1(project_id).
|
||||||
|
_PROJECT_COLOR_PALETTE = [
|
||||||
|
"#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d",
|
||||||
|
"#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8",
|
||||||
|
"#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _color_for_project(project_id: str) -> str:
|
||||||
|
"""Deterministic color assignment from a fixed palette."""
|
||||||
|
h = hashlib.sha1(project_id.encode("utf-8")).digest()[0]
|
||||||
|
return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)]
|
||||||
|
|
||||||
|
|
||||||
|
def _month_short(m: int) -> str:
|
||||||
|
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _month_full(m: int) -> str:
|
||||||
|
return ["January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"][m - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def get_deployment_history_data(
|
||||||
|
db: Session,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Build the calendar data structure for a 12-month window starting at
|
||||||
|
(start_year, start_month).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"months": [
|
||||||
|
{
|
||||||
|
"year": int,
|
||||||
|
"month": int, # 1-12
|
||||||
|
"name": "January",
|
||||||
|
"short_name": "Jan",
|
||||||
|
"year_short": "26",
|
||||||
|
"num_days": int,
|
||||||
|
"first_weekday": int, # 0=Mon..6=Sun (datetime.weekday())
|
||||||
|
"active_days": {
|
||||||
|
day_num: [project_id, project_id, ...] # projects with
|
||||||
|
# ≥1 active assignment
|
||||||
|
# on that day
|
||||||
|
},
|
||||||
|
},
|
||||||
|
... # 12 entries
|
||||||
|
],
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": str,
|
||||||
|
"name": str,
|
||||||
|
"color": str,
|
||||||
|
"status": str,
|
||||||
|
"client_name": str | None,
|
||||||
|
"assignment_count": int, # total assignments contributing to
|
||||||
|
# this 12-month window
|
||||||
|
"first_active": "YYYY-MM-DD" | None,
|
||||||
|
"last_active": "YYYY-MM-DD" | None,
|
||||||
|
},
|
||||||
|
... # only projects with
|
||||||
|
# ≥1 assignment in the
|
||||||
|
# window, sorted by
|
||||||
|
# first_active ASC
|
||||||
|
],
|
||||||
|
"total_assignments": int,
|
||||||
|
"total_active_units": int, # distinct unit_ids across the window
|
||||||
|
"window": {
|
||||||
|
"start_year": int,
|
||||||
|
"start_month": int,
|
||||||
|
"end_year": int,
|
||||||
|
"end_month": int,
|
||||||
|
"first_date": "YYYY-MM-DD",
|
||||||
|
"last_date": "YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Compute window edges.
|
||||||
|
first_date = date(start_year, start_month, 1)
|
||||||
|
# 12 months → end on day-1 of (start + 12)
|
||||||
|
end_year = start_year + ((start_month + 10) // 12)
|
||||||
|
end_month = ((start_month + 10) % 12) + 1
|
||||||
|
last_date = date(end_year, end_month, monthrange(end_year, end_month)[1])
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Fetch every assignment that overlaps the window. An assignment
|
||||||
|
# overlaps if assigned_at <= last_date AND (assigned_until is NULL
|
||||||
|
# OR assigned_until >= first_date).
|
||||||
|
assignments = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time()))
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711 — active
|
||||||
|
UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve referenced projects in one query.
|
||||||
|
proj_ids = {a.project_id for a in assignments}
|
||||||
|
proj_map = {
|
||||||
|
p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all()
|
||||||
|
} if proj_ids else {}
|
||||||
|
|
||||||
|
# Resolve location names in one batch query (used by the Gantt view
|
||||||
|
# for per-bar tooltips).
|
||||||
|
from backend.models import MonitoringLocation
|
||||||
|
loc_ids = {a.location_id for a in assignments}
|
||||||
|
loc_name_map = {
|
||||||
|
l.id: l.name for l in db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.id.in_(loc_ids)
|
||||||
|
).all()
|
||||||
|
} if loc_ids else {}
|
||||||
|
|
||||||
|
# Compute "active days per project" by walking each assignment and
|
||||||
|
# adding every day in its [start, end] ∩ [first_date, last_date].
|
||||||
|
# O(N_assignments × avg_window_days); for a typical fleet this is
|
||||||
|
# bounded (hundreds of assignments × hundreds of days = manageable).
|
||||||
|
# Also collect raw per-assignment bar data for the Gantt view.
|
||||||
|
project_active_days: dict[str, set[date]] = {}
|
||||||
|
project_first_active: dict[str, date] = {}
|
||||||
|
project_last_active: dict[str, date] = {}
|
||||||
|
project_assignment_count: dict[str, int] = {}
|
||||||
|
project_bars: dict[str, list[dict]] = {}
|
||||||
|
distinct_units: set[str] = set()
|
||||||
|
|
||||||
|
for a in assignments:
|
||||||
|
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
|
||||||
|
end_dt = a.assigned_until or now
|
||||||
|
end = min(end_dt.date(), last_date)
|
||||||
|
if end < start:
|
||||||
|
continue
|
||||||
|
days = project_active_days.setdefault(a.project_id, set())
|
||||||
|
d = start
|
||||||
|
while d <= end:
|
||||||
|
days.add(d)
|
||||||
|
d += timedelta(days=1)
|
||||||
|
project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1
|
||||||
|
distinct_units.add(a.unit_id)
|
||||||
|
# Track first/last active dates in the window.
|
||||||
|
prev_first = project_first_active.get(a.project_id)
|
||||||
|
if prev_first is None or start < prev_first:
|
||||||
|
project_first_active[a.project_id] = start
|
||||||
|
prev_last = project_last_active.get(a.project_id)
|
||||||
|
if prev_last is None or end > prev_last:
|
||||||
|
project_last_active[a.project_id] = end
|
||||||
|
|
||||||
|
# Per-assignment bar data — used by the Gantt view's renderer.
|
||||||
|
# `is_active` reflects whether the assignment_until was still NULL
|
||||||
|
# at fetch time (open-ended deployment); the clipped `end` here
|
||||||
|
# is just for visual bar drawing.
|
||||||
|
project_bars.setdefault(a.project_id, []).append({
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
|
||||||
|
"start": start.isoformat(),
|
||||||
|
"end": end.isoformat(),
|
||||||
|
"is_active": a.assigned_until is None,
|
||||||
|
"source": a.source,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build the projects array (sorted by first_active ascending so the
|
||||||
|
# legend reads in deployment-order).
|
||||||
|
projects_data = []
|
||||||
|
for pid, days in project_active_days.items():
|
||||||
|
p = proj_map.get(pid)
|
||||||
|
if not p:
|
||||||
|
# Assignment references a deleted project — surface it anyway
|
||||||
|
# with a placeholder name, since the bars still need a label.
|
||||||
|
projects_data.append({
|
||||||
|
"id": pid,
|
||||||
|
"name": "(deleted project)",
|
||||||
|
"color": _color_for_project(pid),
|
||||||
|
"status": "deleted",
|
||||||
|
"client_name": None,
|
||||||
|
"assignment_count": project_assignment_count.get(pid, 0),
|
||||||
|
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
|
||||||
|
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
|
||||||
|
"bars": project_bars.get(pid, []),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
projects_data.append({
|
||||||
|
"id": pid,
|
||||||
|
"name": p.name,
|
||||||
|
"color": _color_for_project(pid),
|
||||||
|
"status": p.status or "active",
|
||||||
|
"client_name": p.client_name,
|
||||||
|
"assignment_count": project_assignment_count.get(pid, 0),
|
||||||
|
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
|
||||||
|
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
|
||||||
|
"bars": project_bars.get(pid, []),
|
||||||
|
})
|
||||||
|
|
||||||
|
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
|
||||||
|
|
||||||
|
# ── Per-unit view data (Gantt-by-Unit tab) ────────────────────────
|
||||||
|
# Same source assignments, re-grouped by unit_id. Each bar carries
|
||||||
|
# the project's color + name so the renderer can paint by job
|
||||||
|
# without doing a second lookup.
|
||||||
|
unit_bars: dict[str, list[dict]] = {}
|
||||||
|
project_lookup = {p["id"]: p for p in projects_data}
|
||||||
|
for a in assignments:
|
||||||
|
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
|
||||||
|
end_dt = a.assigned_until or now
|
||||||
|
end = min(end_dt.date(), last_date)
|
||||||
|
if end < start:
|
||||||
|
continue
|
||||||
|
p_info = project_lookup.get(a.project_id, {})
|
||||||
|
unit_bars.setdefault(a.unit_id, []).append({
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"project_name": p_info.get("name", "(deleted project)"),
|
||||||
|
"project_color": p_info.get("color", _color_for_project(a.project_id)),
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
|
||||||
|
"start": start.isoformat(),
|
||||||
|
"end": end.isoformat(),
|
||||||
|
"is_active": a.assigned_until is None,
|
||||||
|
"source": a.source,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort units by first-active date so the most-recently-deployed
|
||||||
|
# units sit at the top. Reverse if we want oldest-first.
|
||||||
|
units_data = []
|
||||||
|
for uid, bars in unit_bars.items():
|
||||||
|
bars.sort(key=lambda b: b["start"])
|
||||||
|
first_start = bars[0]["start"]
|
||||||
|
# "active now" flag = any bar is still active
|
||||||
|
any_active = any(b["is_active"] for b in bars)
|
||||||
|
units_data.append({
|
||||||
|
"id": uid,
|
||||||
|
"bars": bars,
|
||||||
|
"first_active": first_start,
|
||||||
|
"assignment_count": len(bars),
|
||||||
|
"any_active": any_active,
|
||||||
|
})
|
||||||
|
units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"]))
|
||||||
|
|
||||||
|
# Now build the months array.
|
||||||
|
months_data = []
|
||||||
|
cur_year, cur_month = start_year, start_month
|
||||||
|
for _ in range(12):
|
||||||
|
num_days = monthrange(cur_year, cur_month)[1]
|
||||||
|
first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun
|
||||||
|
active_days: dict[int, list[str]] = {}
|
||||||
|
for day_num in range(1, num_days + 1):
|
||||||
|
d = date(cur_year, cur_month, day_num)
|
||||||
|
day_projects = [
|
||||||
|
pid for pid, days in project_active_days.items()
|
||||||
|
if d in days
|
||||||
|
]
|
||||||
|
if day_projects:
|
||||||
|
# Sort by the project's color-stable order so bars don't
|
||||||
|
# jitter between days.
|
||||||
|
day_projects.sort()
|
||||||
|
active_days[day_num] = day_projects
|
||||||
|
months_data.append({
|
||||||
|
"year": cur_year,
|
||||||
|
"month": cur_month,
|
||||||
|
"name": _month_full(cur_month),
|
||||||
|
"short_name": _month_short(cur_month),
|
||||||
|
"year_short": f"{cur_year % 100:02d}",
|
||||||
|
"num_days": num_days,
|
||||||
|
"first_weekday": first_weekday,
|
||||||
|
"active_days": active_days,
|
||||||
|
})
|
||||||
|
# Advance one month.
|
||||||
|
if cur_month == 12:
|
||||||
|
cur_year += 1
|
||||||
|
cur_month = 1
|
||||||
|
else:
|
||||||
|
cur_month += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"months": months_data,
|
||||||
|
"projects": projects_data,
|
||||||
|
"units": units_data,
|
||||||
|
"total_assignments": len(assignments),
|
||||||
|
"total_active_units": len(distinct_units),
|
||||||
|
"window": {
|
||||||
|
"start_year": start_year,
|
||||||
|
"start_month": start_month,
|
||||||
|
"end_year": end_year,
|
||||||
|
"end_month": end_month,
|
||||||
|
"first_date": first_date.isoformat(),
|
||||||
|
"last_date": last_date.isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_deployments_on_day(
|
||||||
|
db: Session,
|
||||||
|
target_date: date,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Return the list of (unit, location, project) tuples that were
|
||||||
|
actively assigned on a specific calendar date. Used for the
|
||||||
|
day-detail side panel when an operator clicks a day cell.
|
||||||
|
"""
|
||||||
|
from backend.models import MonitoringLocation, RosterUnit
|
||||||
|
|
||||||
|
day_start = datetime.combine(target_date, datetime.min.time())
|
||||||
|
day_end = datetime.combine(target_date, datetime.max.time())
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.assigned_at <= day_end)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
UnitAssignment.assigned_until >= day_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(UnitAssignment.project_id, UnitAssignment.unit_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
loc_ids = {a.location_id for a in rows}
|
||||||
|
proj_ids = {a.project_id for a in rows}
|
||||||
|
loc_map = {
|
||||||
|
l.id: l for l in db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.id.in_(loc_ids)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
proj_map = {
|
||||||
|
p.id: p for p in db.query(Project).filter(
|
||||||
|
Project.id.in_(proj_ids)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for a in rows:
|
||||||
|
loc = loc_map.get(a.location_id)
|
||||||
|
proj = proj_map.get(a.project_id)
|
||||||
|
results.append({
|
||||||
|
"assignment_id": a.id,
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc.name if loc else "(unknown location)",
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"project_name": proj.name if proj else "(deleted project)",
|
||||||
|
"project_color": _color_for_project(a.project_id),
|
||||||
|
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
|
||||||
|
"assigned_until": a.assigned_until.isoformat() if a.assigned_until else None,
|
||||||
|
"is_active": a.assigned_until is None,
|
||||||
|
"source": a.source,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""
|
||||||
|
Deployment timeline service — replaces the legacy `deployment_records`-driven
|
||||||
|
timeline on the seismograph unit detail page.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- `unit_assignments` is the authoritative source for "where was this unit"
|
||||||
|
(one row per location/time-window). Auto-written by the project location
|
||||||
|
swap/assign/unassign/update workflows.
|
||||||
|
- `unit_history` is the audit log for non-location state changes
|
||||||
|
(calibration toggles, retirement, allocation, etc.).
|
||||||
|
- SFM events are overlaid per assignment window to show "what was the unit
|
||||||
|
actually doing during this deployment" (count + peak PVS + last-event).
|
||||||
|
|
||||||
|
Gaps between assignments are emitted as synthetic "gap" entries so operators
|
||||||
|
can see when the unit was idle vs out-of-service.
|
||||||
|
|
||||||
|
`deployment_records` is being deprecated; this module does not read it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.models import (
|
||||||
|
UnitAssignment,
|
||||||
|
UnitHistory,
|
||||||
|
MonitoringLocation,
|
||||||
|
Project,
|
||||||
|
RosterUnit,
|
||||||
|
)
|
||||||
|
from backend.services.sfm_events import (
|
||||||
|
SFM_BASE_URL,
|
||||||
|
_fetch_events_for_serial,
|
||||||
|
_iso_utc,
|
||||||
|
)
|
||||||
|
from backend.utils.timezone import utc_to_local
|
||||||
|
|
||||||
|
log = logging.getLogger("backend.services.deployment_timeline")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_local(dt) -> Optional[str]:
|
||||||
|
"""Serialize a datetime / ISO-string in the user's configured timezone.
|
||||||
|
|
||||||
|
The timeline frontend slices these strings to character 19 to produce
|
||||||
|
"YYYY-MM-DD HH:MM:SS" — no JS-side timezone conversion happens. We
|
||||||
|
therefore emit *already-local* timestamps here so the displayed time
|
||||||
|
matches what the operator actually saw on the wall clock.
|
||||||
|
|
||||||
|
Accepts either a ``datetime`` (DB column) or an ISO ``str`` (SFM
|
||||||
|
response). Returns ``None`` for ``None`` input. Naive ISO strings
|
||||||
|
from SFM are interpreted as UTC.
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if isinstance(dt, str):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(dt.replace("Z", "").replace(" ", "T"))
|
||||||
|
except ValueError:
|
||||||
|
return dt # give up gracefully — emit whatever SFM sent
|
||||||
|
local = utc_to_local(dt)
|
||||||
|
if local is None:
|
||||||
|
return None
|
||||||
|
return local.replace(tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
||||||
|
# clutter from a sub-second handoff during a swap workflow.
|
||||||
|
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
||||||
|
|
||||||
|
# When detecting "mergeable" groups of consecutive same-location assignments,
|
||||||
|
# treat assignments separated by no more than this many seconds as adjacent.
|
||||||
|
# Generous enough to catch overnight handoffs and weekend gaps where the
|
||||||
|
# operator forgot to log, but tight enough that genuinely separate
|
||||||
|
# deployments months apart don't get suggested for merging.
|
||||||
|
_MERGE_GAP_TOLERANCE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||||
|
|
||||||
|
# Per-call timeout when querying SFM for the event overlay.
|
||||||
|
_SFM_TIMEOUT = 10.0
|
||||||
|
_SFM_FETCH_CEILING = 5000
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def deployment_timeline_for_unit(
|
||||||
|
db: Session,
|
||||||
|
unit_id: str,
|
||||||
|
*,
|
||||||
|
include_event_overlay: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a chronological timeline for a unit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"unit_id": str,
|
||||||
|
"device_type": str,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"kind": "assignment" | "gap" | "state_change",
|
||||||
|
"starts_at": ISO timestamp,
|
||||||
|
"ends_at": ISO timestamp | None,
|
||||||
|
"duration_days": float | None,
|
||||||
|
# — assignment-only fields —
|
||||||
|
"assignment_id": str,
|
||||||
|
"location_id": str,
|
||||||
|
"location_name": str,
|
||||||
|
"project_id": str,
|
||||||
|
"project_name": str,
|
||||||
|
"is_active": bool,
|
||||||
|
"event_overlay": {event_count, peak_pvs, peak_pvs_at, last_event}
|
||||||
|
or None if include_event_overlay=False,
|
||||||
|
"notes": str | None,
|
||||||
|
# — gap-only fields —
|
||||||
|
"context": "between assignments" | None,
|
||||||
|
# — state_change-only fields —
|
||||||
|
"change_type": str,
|
||||||
|
"field_name": str | None,
|
||||||
|
"old_value": str | None,
|
||||||
|
"new_value": str | None,
|
||||||
|
"source": str,
|
||||||
|
"history_notes": str | None,
|
||||||
|
},
|
||||||
|
... # newest first
|
||||||
|
],
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
return {"unit_id": unit_id, "device_type": None, "entries": []}
|
||||||
|
|
||||||
|
# 1. Load assignments + their location/project lookups in bulk.
|
||||||
|
assignments = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.unit_id == unit_id)
|
||||||
|
.order_by(UnitAssignment.assigned_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
loc_ids = {a.location_id for a in assignments}
|
||||||
|
proj_ids = {a.project_id for a in assignments}
|
||||||
|
loc_map = {
|
||||||
|
l.id: l for l in db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.id.in_(loc_ids)
|
||||||
|
).all()
|
||||||
|
} if loc_ids else {}
|
||||||
|
proj_map = {
|
||||||
|
p.id: p for p in db.query(Project).filter(
|
||||||
|
Project.id.in_(proj_ids)
|
||||||
|
).all()
|
||||||
|
} if proj_ids else {}
|
||||||
|
|
||||||
|
# 2. Load relevant unit_history rows. We surface state changes that
|
||||||
|
# operators care about on a deployment timeline: calibration status,
|
||||||
|
# retirement, deployed flag, allocation, calibration date, and the
|
||||||
|
# assignment_* events we just added (those are redundant with the
|
||||||
|
# assignment rows themselves, so we skip them to avoid double-rendering).
|
||||||
|
interesting_change_types = (
|
||||||
|
"calibration_status_change",
|
||||||
|
"retired_change",
|
||||||
|
"deployed_change",
|
||||||
|
"allocation_change",
|
||||||
|
"last_calibrated_change",
|
||||||
|
"next_calibration_due_change",
|
||||||
|
)
|
||||||
|
history = (
|
||||||
|
db.query(UnitHistory)
|
||||||
|
.filter(UnitHistory.unit_id == unit_id)
|
||||||
|
.filter(UnitHistory.change_type.in_(interesting_change_types))
|
||||||
|
.order_by(UnitHistory.changed_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# 3. Optionally fetch SFM event overlay for each assignment window.
|
||||||
|
# Concurrent fan-out via httpx + asyncio.gather.
|
||||||
|
overlays: dict[str, dict] = {}
|
||||||
|
if include_event_overlay and assignments and unit.device_type == "seismograph":
|
||||||
|
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT) as client:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
_fetch_events_for_serial(
|
||||||
|
client,
|
||||||
|
serial=unit_id,
|
||||||
|
from_dt=a.assigned_at,
|
||||||
|
to_dt=a.assigned_until or now,
|
||||||
|
false_trigger=None,
|
||||||
|
limit=_SFM_FETCH_CEILING,
|
||||||
|
)
|
||||||
|
for a in assignments
|
||||||
|
),
|
||||||
|
return_exceptions=False,
|
||||||
|
)
|
||||||
|
for a, events in zip(assignments, results):
|
||||||
|
peak = None
|
||||||
|
peak_at = None
|
||||||
|
last_ev = None
|
||||||
|
for ev in events:
|
||||||
|
pvs = ev.get("peak_vector_sum")
|
||||||
|
if pvs is not None and (peak is None or pvs > peak):
|
||||||
|
peak = pvs
|
||||||
|
peak_at = ev.get("timestamp")
|
||||||
|
ts = ev.get("timestamp")
|
||||||
|
if ts and (last_ev is None or ts > last_ev):
|
||||||
|
last_ev = ts
|
||||||
|
overlays[a.id] = {
|
||||||
|
"event_count": len(events),
|
||||||
|
"peak_pvs": peak,
|
||||||
|
"peak_pvs_at": _iso_local(peak_at),
|
||||||
|
"last_event": _iso_local(last_ev),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||||
|
# consecutive assignments, then add state-change rows from unit_history.
|
||||||
|
entries: list[dict] = []
|
||||||
|
|
||||||
|
for idx, a in enumerate(assignments):
|
||||||
|
loc = loc_map.get(a.location_id)
|
||||||
|
proj = proj_map.get(a.project_id)
|
||||||
|
is_active = a.assigned_until is None
|
||||||
|
ends_at = a.assigned_until or now
|
||||||
|
duration_days = (ends_at - a.assigned_at).total_seconds() / 86400 if a.assigned_at else None
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"kind": "assignment",
|
||||||
|
"starts_at": _iso_local(a.assigned_at),
|
||||||
|
"ends_at": _iso_local(a.assigned_until),
|
||||||
|
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
||||||
|
"assignment_id": a.id,
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc.name if loc else None,
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"project_name": proj.name if proj else None,
|
||||||
|
"is_active": is_active,
|
||||||
|
"notes": a.notes,
|
||||||
|
"event_overlay": overlays.get(a.id),
|
||||||
|
}
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
# Gap detection: from the end of this assignment to the start of the
|
||||||
|
# next one. Only emit gaps that are at least _MIN_GAP_SECONDS long
|
||||||
|
# so trivial sub-second handoffs during swaps don't clutter the view.
|
||||||
|
if idx + 1 < len(assignments):
|
||||||
|
next_a = assignments[idx + 1]
|
||||||
|
gap_start = a.assigned_until or now
|
||||||
|
gap_end = next_a.assigned_at
|
||||||
|
gap_seconds = (gap_end - gap_start).total_seconds() if gap_end and gap_start else 0
|
||||||
|
if gap_seconds >= _MIN_GAP_SECONDS:
|
||||||
|
entries.append({
|
||||||
|
"kind": "gap",
|
||||||
|
"starts_at": _iso_local(gap_start),
|
||||||
|
"ends_at": _iso_local(gap_end),
|
||||||
|
"duration_days": round(gap_seconds / 86400, 1),
|
||||||
|
"context": "between assignments",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. State changes — interleaved by timestamp. Skip no-op rows where
|
||||||
|
# old_value == new_value (an artifact of the legacy record_history()
|
||||||
|
# being called on every save regardless of whether the field changed).
|
||||||
|
for h in history:
|
||||||
|
if h.old_value == h.new_value:
|
||||||
|
continue
|
||||||
|
entries.append({
|
||||||
|
"kind": "state_change",
|
||||||
|
"starts_at": _iso_local(h.changed_at),
|
||||||
|
"ends_at": None,
|
||||||
|
"duration_days": None,
|
||||||
|
"change_type": h.change_type,
|
||||||
|
"field_name": h.field_name,
|
||||||
|
"old_value": h.old_value,
|
||||||
|
"new_value": h.new_value,
|
||||||
|
"source": h.source,
|
||||||
|
"history_notes": h.notes,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 6. Detect mergeable groups — runs of consecutive assignments to the
|
||||||
|
# same location with small gaps between them. Each group becomes a
|
||||||
|
# list of assignment_ids; the UI offers a "Merge into one" action
|
||||||
|
# on any group >= 2.
|
||||||
|
merge_groups: list[list[str]] = []
|
||||||
|
if len(assignments) >= 2:
|
||||||
|
# Sort ascending for the linear scan.
|
||||||
|
sorted_assignments = sorted(assignments, key=lambda a: a.assigned_at)
|
||||||
|
cur_group: list[UnitAssignment] = [sorted_assignments[0]]
|
||||||
|
for a in sorted_assignments[1:]:
|
||||||
|
prev = cur_group[-1]
|
||||||
|
same_location = a.location_id == prev.location_id
|
||||||
|
prev_end = prev.assigned_until or now
|
||||||
|
gap_seconds = (a.assigned_at - prev_end).total_seconds() if a.assigned_at else 0
|
||||||
|
# Within tolerance and same location → extend the current group.
|
||||||
|
# Negative gaps (overlap) also count as adjacent.
|
||||||
|
if same_location and gap_seconds <= _MERGE_GAP_TOLERANCE_SECONDS:
|
||||||
|
cur_group.append(a)
|
||||||
|
else:
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
cur_group = [a]
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
|
||||||
|
# 7. Sort newest first. Active assignments (no end) sort by start time,
|
||||||
|
# same as everything else.
|
||||||
|
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit.id,
|
||||||
|
"device_type": unit.device_type,
|
||||||
|
"entries": entries,
|
||||||
|
# List of assignment_id lists; each inner list is a mergeable group.
|
||||||
|
# Empty if nothing is mergeable. UI shows a "Merge" button on any
|
||||||
|
# row whose assignment_id appears in a group.
|
||||||
|
"merge_groups": merge_groups,
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ from sqlalchemy import and_, or_
|
|||||||
|
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
RosterUnit, JobReservation, JobReservationUnit,
|
RosterUnit, JobReservation, JobReservationUnit,
|
||||||
UserPreferences, Project
|
UserPreferences, Project, DeploymentRecord
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +70,19 @@ def get_unit_reservations_on_date(
|
|||||||
return reservations
|
return reservations
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_deployment(db: Session, unit_id: str) -> Optional[DeploymentRecord]:
|
||||||
|
"""Return the active (unreturned) deployment record for a unit, or None."""
|
||||||
|
return (
|
||||||
|
db.query(DeploymentRecord)
|
||||||
|
.filter(
|
||||||
|
DeploymentRecord.unit_id == unit_id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
)
|
||||||
|
.order_by(DeploymentRecord.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_unit_available_on_date(
|
def is_unit_available_on_date(
|
||||||
db: Session,
|
db: Session,
|
||||||
unit: RosterUnit,
|
unit: RosterUnit,
|
||||||
@@ -82,8 +95,8 @@ def is_unit_available_on_date(
|
|||||||
Returns:
|
Returns:
|
||||||
(is_available, status, reservation_name)
|
(is_available, status, reservation_name)
|
||||||
- is_available: True if unit can be assigned to new work
|
- is_available: True if unit can be assigned to new work
|
||||||
- status: "available", "reserved", "expired", "retired", "needs_calibration"
|
- status: "available", "reserved", "expired", "retired", "needs_calibration", "in_field"
|
||||||
- reservation_name: Name of blocking reservation (if any)
|
- reservation_name: Name of blocking reservation or project ref (if any)
|
||||||
"""
|
"""
|
||||||
# Check if retired
|
# Check if retired
|
||||||
if unit.retired:
|
if unit.retired:
|
||||||
@@ -96,6 +109,12 @@ def is_unit_available_on_date(
|
|||||||
if cal_status == "needs_calibration":
|
if cal_status == "needs_calibration":
|
||||||
return False, "needs_calibration", None
|
return False, "needs_calibration", None
|
||||||
|
|
||||||
|
# Check for an active deployment record (unit is physically in the field)
|
||||||
|
active_deployment = get_active_deployment(db, unit.id)
|
||||||
|
if active_deployment:
|
||||||
|
label = active_deployment.project_ref or "Field deployment"
|
||||||
|
return False, "in_field", label
|
||||||
|
|
||||||
# Check if already reserved
|
# Check if already reserved
|
||||||
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
|
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
|
||||||
if reservations:
|
if reservations:
|
||||||
@@ -136,6 +155,7 @@ def get_day_summary(
|
|||||||
expired_units = []
|
expired_units = []
|
||||||
expiring_soon_units = []
|
expiring_soon_units = []
|
||||||
needs_calibration_units = []
|
needs_calibration_units = []
|
||||||
|
in_field_units = []
|
||||||
cal_expiring_today = [] # Units whose calibration expires ON this day
|
cal_expiring_today = [] # Units whose calibration expires ON this day
|
||||||
|
|
||||||
for unit in units:
|
for unit in units:
|
||||||
@@ -167,6 +187,9 @@ def get_day_summary(
|
|||||||
available_units.append(unit_info)
|
available_units.append(unit_info)
|
||||||
if cal_status == "expiring_soon":
|
if cal_status == "expiring_soon":
|
||||||
expiring_soon_units.append(unit_info)
|
expiring_soon_units.append(unit_info)
|
||||||
|
elif status == "in_field":
|
||||||
|
unit_info["project_ref"] = reservation_name
|
||||||
|
in_field_units.append(unit_info)
|
||||||
elif status == "reserved":
|
elif status == "reserved":
|
||||||
unit_info["reservation_name"] = reservation_name
|
unit_info["reservation_name"] = reservation_name
|
||||||
reserved_units.append(unit_info)
|
reserved_units.append(unit_info)
|
||||||
@@ -207,6 +230,7 @@ def get_day_summary(
|
|||||||
"date": check_date.isoformat(),
|
"date": check_date.isoformat(),
|
||||||
"device_type": device_type,
|
"device_type": device_type,
|
||||||
"available_units": available_units,
|
"available_units": available_units,
|
||||||
|
"in_field_units": in_field_units,
|
||||||
"reserved_units": reserved_units,
|
"reserved_units": reserved_units,
|
||||||
"expired_units": expired_units,
|
"expired_units": expired_units,
|
||||||
"expiring_soon_units": expiring_soon_units,
|
"expiring_soon_units": expiring_soon_units,
|
||||||
@@ -215,6 +239,7 @@ def get_day_summary(
|
|||||||
"reservations": reservation_list,
|
"reservations": reservation_list,
|
||||||
"counts": {
|
"counts": {
|
||||||
"available": len(available_units),
|
"available": len(available_units),
|
||||||
|
"in_field": len(in_field_units),
|
||||||
"reserved": len(reserved_units),
|
"reserved": len(reserved_units),
|
||||||
"expired": len(expired_units),
|
"expired": len(expired_units),
|
||||||
"expiring_soon": len(expiring_soon_units),
|
"expiring_soon": len(expiring_soon_units),
|
||||||
@@ -285,6 +310,14 @@ def get_calendar_year_data(
|
|||||||
unit_reservations[unit_id] = []
|
unit_reservations[unit_id] = []
|
||||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||||
|
|
||||||
|
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||||
|
unit_ids = [u.id for u in units]
|
||||||
|
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||||
|
DeploymentRecord.unit_id.in_(unit_ids),
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).all()
|
||||||
|
unit_in_field = {row.unit_id for row in active_deployments}
|
||||||
|
|
||||||
# Generate data for each month
|
# Generate data for each month
|
||||||
months_data = {}
|
months_data = {}
|
||||||
|
|
||||||
@@ -301,6 +334,7 @@ def get_calendar_year_data(
|
|||||||
|
|
||||||
while current_day <= last_day:
|
while current_day <= last_day:
|
||||||
available = 0
|
available = 0
|
||||||
|
in_field = 0
|
||||||
reserved = 0
|
reserved = 0
|
||||||
expired = 0
|
expired = 0
|
||||||
expiring_soon = 0
|
expiring_soon = 0
|
||||||
@@ -328,6 +362,11 @@ def get_calendar_year_data(
|
|||||||
needs_cal += 1
|
needs_cal += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check active deployment record (in field)
|
||||||
|
if unit.id in unit_in_field:
|
||||||
|
in_field += 1
|
||||||
|
continue
|
||||||
|
|
||||||
# Check if reserved
|
# Check if reserved
|
||||||
is_reserved = False
|
is_reserved = False
|
||||||
if unit.id in unit_reservations:
|
if unit.id in unit_reservations:
|
||||||
@@ -346,6 +385,7 @@ def get_calendar_year_data(
|
|||||||
|
|
||||||
days_data[current_day.day] = {
|
days_data[current_day.day] = {
|
||||||
"available": available,
|
"available": available,
|
||||||
|
"in_field": in_field,
|
||||||
"reserved": reserved,
|
"reserved": reserved,
|
||||||
"expired": expired,
|
"expired": expired,
|
||||||
"expiring_soon": expiring_soon,
|
"expiring_soon": expiring_soon,
|
||||||
@@ -462,6 +502,14 @@ def get_rolling_calendar_data(
|
|||||||
unit_reservations[unit_id] = []
|
unit_reservations[unit_id] = []
|
||||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||||
|
|
||||||
|
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||||
|
unit_ids = [u.id for u in units]
|
||||||
|
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||||
|
DeploymentRecord.unit_id.in_(unit_ids),
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).all()
|
||||||
|
unit_in_field = {row.unit_id for row in active_deployments}
|
||||||
|
|
||||||
# Generate data for each of the 12 months
|
# Generate data for each of the 12 months
|
||||||
months_data = []
|
months_data = []
|
||||||
current_year = start_year
|
current_year = start_year
|
||||||
@@ -640,28 +688,37 @@ def get_available_units_for_period(
|
|||||||
for a in assigned:
|
for a in assigned:
|
||||||
reserved_unit_ids.add(a.unit_id)
|
reserved_unit_ids.add(a.unit_id)
|
||||||
|
|
||||||
|
# Get units with active deployment records (still in the field)
|
||||||
|
unit_ids = [u.id for u in units]
|
||||||
|
active_deps = db.query(DeploymentRecord.unit_id).filter(
|
||||||
|
DeploymentRecord.unit_id.in_(unit_ids),
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).all()
|
||||||
|
in_field_unit_ids = {row.unit_id for row in active_deps}
|
||||||
|
|
||||||
available_units = []
|
available_units = []
|
||||||
for unit in units:
|
for unit in units:
|
||||||
# Check if already reserved
|
# Check if already reserved
|
||||||
if unit.id in reserved_unit_ids:
|
if unit.id in reserved_unit_ids:
|
||||||
continue
|
continue
|
||||||
|
# Check if currently in the field
|
||||||
|
if unit.id in in_field_unit_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
# Check calibration through end of period
|
if unit.last_calibrated:
|
||||||
if not unit.last_calibrated:
|
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||||
continue # Needs calibration
|
cal_status = get_calibration_status(unit, end_date, warning_days)
|
||||||
|
else:
|
||||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
expiry_date = None
|
||||||
if expiry_date <= end_date:
|
cal_status = "needs_calibration"
|
||||||
continue # Calibration expires during period
|
|
||||||
|
|
||||||
cal_status = get_calibration_status(unit, end_date, warning_days)
|
|
||||||
|
|
||||||
available_units.append({
|
available_units.append({
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"last_calibrated": unit.last_calibrated.isoformat(),
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||||
"expiry_date": expiry_date.isoformat(),
|
"expiry_date": expiry_date.isoformat() if expiry_date else None,
|
||||||
"calibration_status": cal_status,
|
"calibration_status": cal_status,
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
|
"out_for_calibration": unit.out_for_calibration or False,
|
||||||
"note": unit.note or ""
|
"note": unit.note or ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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]
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
"""
|
||||||
|
SFM events service — bridge between terra-view's UnitAssignment time-windows
|
||||||
|
and the SFM (seismo-relay) events store.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
1. Terra-view owns the *assignment graph*: which seismograph was at which
|
||||||
|
monitoring location during which time window (UnitAssignment rows).
|
||||||
|
2. SFM owns the *events store*: triggered waveform events keyed by
|
||||||
|
(serial, timestamp), forwarded from Blastware ACH by series3-watcher.
|
||||||
|
3. This module fans out the assignments for a given location, queries SFM
|
||||||
|
for the events emitted by each (serial, window) pair concurrently, and
|
||||||
|
unions/sorts/paginates the results.
|
||||||
|
|
||||||
|
SFM remains the single source of truth for events. Terra-view does not
|
||||||
|
copy events into its own DB; every query hits SFM live.
|
||||||
|
|
||||||
|
The events_for_location helper is also reused by Phase 3 (project-level
|
||||||
|
roll-up) to aggregate across every location in a project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.models import UnitAssignment, RosterUnit, MonitoringLocation, Project
|
||||||
|
|
||||||
|
log = logging.getLogger("backend.services.sfm_events")
|
||||||
|
|
||||||
|
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||||
|
|
||||||
|
# Per-request timeout when calling SFM /db/events. SFM is local on the
|
||||||
|
# docker network so this should be fast; bump if you start seeing timeouts.
|
||||||
|
_SFM_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
# Max events we ever fetch per (serial, window) call to SFM. Must match
|
||||||
|
# SFM's own /db/events max limit (currently 5000). The user-facing display
|
||||||
|
# limit is independent — we over-fetch up to this cap so summary stats are
|
||||||
|
# accurate, then trim the displayed list to the requested limit.
|
||||||
|
_SFM_FETCH_CEILING = 5000
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_utc(dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
"""Render a datetime in the ISO format SFM /db/events expects."""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
# SFM parses naive ISO strings as UTC; strip tzinfo for consistency.
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return dt.isoformat(sep=" ", timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _intersect_window(
|
||||||
|
assignment_start: datetime,
|
||||||
|
assignment_end: Optional[datetime],
|
||||||
|
filter_from: Optional[datetime],
|
||||||
|
filter_to: Optional[datetime],
|
||||||
|
now: datetime,
|
||||||
|
) -> Optional[tuple[datetime, datetime]]:
|
||||||
|
"""Intersect an assignment window with the requested filter window.
|
||||||
|
|
||||||
|
Returns (effective_start, effective_end) or None if there's no overlap.
|
||||||
|
Open-ended assignments (assigned_until=NULL) are bounded by `now`.
|
||||||
|
"""
|
||||||
|
a_end = assignment_end or now
|
||||||
|
if filter_from and a_end <= filter_from:
|
||||||
|
return None
|
||||||
|
if filter_to and assignment_start >= filter_to:
|
||||||
|
return None
|
||||||
|
start = max(assignment_start, filter_from) if filter_from else assignment_start
|
||||||
|
end = min(a_end, filter_to) if filter_to else a_end
|
||||||
|
if end <= start:
|
||||||
|
return None
|
||||||
|
return (start, end)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_events_for_serial(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
serial: str,
|
||||||
|
*,
|
||||||
|
from_dt: datetime,
|
||||||
|
to_dt: datetime,
|
||||||
|
false_trigger: Optional[bool],
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Issue one /db/events call to SFM for one (serial, window) pair."""
|
||||||
|
params: dict[str, str] = {
|
||||||
|
"serial": serial,
|
||||||
|
"from_dt": _iso_utc(from_dt) or "",
|
||||||
|
"to_dt": _iso_utc(to_dt) or "",
|
||||||
|
"limit": str(limit),
|
||||||
|
}
|
||||||
|
if false_trigger is not None:
|
||||||
|
params["false_trigger"] = "true" if false_trigger else "false"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"{SFM_BASE_URL}/db/events", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log.warning("SFM /db/events failed for serial=%s: %s", serial, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
payload = resp.json()
|
||||||
|
events = payload.get("events", []) or []
|
||||||
|
# Strip waveform_blob if present — it's the big per-event binary and we
|
||||||
|
# don't render it in the list view. SFM returns it by default.
|
||||||
|
for ev in events:
|
||||||
|
ev.pop("waveform_blob", None)
|
||||||
|
ev.pop("a5_pickle_filename", None)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def events_for_location(
|
||||||
|
db: Session,
|
||||||
|
location_id: str,
|
||||||
|
*,
|
||||||
|
from_dt: Optional[datetime] = None,
|
||||||
|
to_dt: Optional[datetime] = None,
|
||||||
|
false_trigger: Optional[bool] = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> dict:
|
||||||
|
"""Fan out UnitAssignment rows for `location_id` and union SFM events.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"events": [merged event dicts, newest first, capped at limit],
|
||||||
|
"count": total events found across all windows (pre-cap),
|
||||||
|
"stats": {event_count, peak_pvs, peak_pvs_at,
|
||||||
|
last_event, false_trigger_count},
|
||||||
|
"assignments_used": [{unit_id, assigned_at, assigned_until,
|
||||||
|
events_in_window}, ...],
|
||||||
|
}
|
||||||
|
|
||||||
|
The "events outside any assignment window" rule (Phase 1 design decision):
|
||||||
|
events whose timestamp falls outside every assignment window are simply
|
||||||
|
not fetched — we only ask SFM for events inside the intersected windows.
|
||||||
|
Those orphan events surface under the per-unit detail page in Phase 2.
|
||||||
|
"""
|
||||||
|
# 1. Fetch all assignments (active + closed) for the location.
|
||||||
|
assignments = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.location_id == location_id)
|
||||||
|
.filter(UnitAssignment.device_type == "seismograph")
|
||||||
|
.order_by(UnitAssignment.assigned_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not assignments:
|
||||||
|
return {
|
||||||
|
"events": [],
|
||||||
|
"count": 0,
|
||||||
|
"stats": _empty_stats(),
|
||||||
|
"assignments_used": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# 2. For each assignment, compute the effective (start, end) window after
|
||||||
|
# intersecting with the requested filter range. Drop assignments that
|
||||||
|
# don't overlap the filter window.
|
||||||
|
fetch_specs: list[tuple[UnitAssignment, datetime, datetime]] = []
|
||||||
|
for a in assignments:
|
||||||
|
window = _intersect_window(a.assigned_at, a.assigned_until, from_dt, to_dt, now)
|
||||||
|
if window is not None:
|
||||||
|
fetch_specs.append((a, window[0], window[1]))
|
||||||
|
|
||||||
|
if not fetch_specs:
|
||||||
|
return {
|
||||||
|
"events": [],
|
||||||
|
"count": 0,
|
||||||
|
"stats": _empty_stats(),
|
||||||
|
"assignments_used": [
|
||||||
|
{
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"assigned_at": _iso_utc(a.assigned_at),
|
||||||
|
"assigned_until": _iso_utc(a.assigned_until),
|
||||||
|
"events_in_window": 0,
|
||||||
|
}
|
||||||
|
for a in assignments
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Concurrent SFM fetches. We over-fetch (up to _SFM_FETCH_CEILING per
|
||||||
|
# window) so summary stats reflect the true peak/last/count across the
|
||||||
|
# full filter window, not just what fits in the user's display limit.
|
||||||
|
# The displayed event list is trimmed to `limit` after merge.
|
||||||
|
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
|
||||||
|
per_window_lists = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
_fetch_events_for_serial(
|
||||||
|
client,
|
||||||
|
serial=a.unit_id,
|
||||||
|
from_dt=start,
|
||||||
|
to_dt=end,
|
||||||
|
false_trigger=false_trigger,
|
||||||
|
limit=_SFM_FETCH_CEILING,
|
||||||
|
)
|
||||||
|
for a, start, end in fetch_specs
|
||||||
|
),
|
||||||
|
return_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Build the per-assignment event counts (transparency for the operator).
|
||||||
|
spec_event_counts: dict[str, int] = {}
|
||||||
|
for (a, _start, _end), evs in zip(fetch_specs, per_window_lists):
|
||||||
|
spec_event_counts[a.id] = len(evs)
|
||||||
|
|
||||||
|
# 5. Union, sort newest-first, cap.
|
||||||
|
merged: list[dict] = []
|
||||||
|
for evs in per_window_lists:
|
||||||
|
merged.extend(evs)
|
||||||
|
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||||
|
total_count = len(merged)
|
||||||
|
capped = merged[:limit]
|
||||||
|
|
||||||
|
# 6. Compute summary stats over the full merged set (not the capped one).
|
||||||
|
stats = _compute_stats(merged)
|
||||||
|
|
||||||
|
# 7. Build the assignments_used report (every assignment, in chronological
|
||||||
|
# order, with its event count — even ones that fell outside the filter
|
||||||
|
# window so the operator sees them but with count=0).
|
||||||
|
assignments_used = []
|
||||||
|
for a in assignments:
|
||||||
|
assignments_used.append(
|
||||||
|
{
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"assignment_id": a.id,
|
||||||
|
"assigned_at": _iso_utc(a.assigned_at),
|
||||||
|
"assigned_until": _iso_utc(a.assigned_until),
|
||||||
|
"events_in_window": spec_event_counts.get(a.id, 0),
|
||||||
|
"status": a.status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"events": capped,
|
||||||
|
"count": total_count,
|
||||||
|
"stats": stats,
|
||||||
|
"assignments_used": assignments_used,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-unit (cross-project) view ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def events_for_unit(
|
||||||
|
db: Session,
|
||||||
|
unit_id: str,
|
||||||
|
*,
|
||||||
|
bucket: str = "all", # "all" | "attributed" | "unattributed"
|
||||||
|
from_dt: Optional[datetime] = None,
|
||||||
|
to_dt: Optional[datetime] = None,
|
||||||
|
false_trigger: Optional[bool] = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> dict:
|
||||||
|
"""Return events for a unit annotated with their assignment attribution.
|
||||||
|
|
||||||
|
Unlike events_for_location (which queries SFM per assignment window), this
|
||||||
|
helper queries SFM for ALL events for the serial within the optional
|
||||||
|
[from_dt, to_dt] filter, then walks each event against the unit's
|
||||||
|
UnitAssignment intervals to compute attribution.
|
||||||
|
|
||||||
|
Bucket semantics:
|
||||||
|
- "all": every event, attributed or not
|
||||||
|
- "attributed": events that fall inside at least one assignment window
|
||||||
|
- "unattributed": events with no overlapping assignment (the diagnostic
|
||||||
|
bucket — operator should fix assignment dates to
|
||||||
|
attribute these)
|
||||||
|
|
||||||
|
Each event gets an extra `attribution` field:
|
||||||
|
{assignment_id, location_id, location_name, project_id, project_name,
|
||||||
|
assigned_at, assigned_until} or None
|
||||||
|
|
||||||
|
Unattributed events also get a `nearest_assignment` field with the
|
||||||
|
same shape plus `delta_days` (signed; negative = event before assignment).
|
||||||
|
"""
|
||||||
|
# 1. Pull all assignments for this unit (any device_type — caller has
|
||||||
|
# already filtered by seismograph in the route). Order matters: we
|
||||||
|
# want the earliest-start assignment first so attribution prefers the
|
||||||
|
# chronologically-first overlap when there are simultaneous active
|
||||||
|
# assignments at different locations (rare but possible).
|
||||||
|
assignments = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.unit_id == unit_id)
|
||||||
|
.order_by(UnitAssignment.assigned_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve location + project names once.
|
||||||
|
loc_ids = {a.location_id for a in assignments}
|
||||||
|
proj_ids = {a.project_id for a in assignments}
|
||||||
|
loc_map = {
|
||||||
|
l.id: l for l in db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.id.in_(loc_ids)
|
||||||
|
).all()
|
||||||
|
} if loc_ids else {}
|
||||||
|
proj_map = {
|
||||||
|
p.id: p for p in db.query(Project).filter(
|
||||||
|
Project.id.in_(proj_ids)
|
||||||
|
).all()
|
||||||
|
} if proj_ids else {}
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
def _attr_dict(a: UnitAssignment) -> dict:
|
||||||
|
loc = loc_map.get(a.location_id)
|
||||||
|
proj = proj_map.get(a.project_id)
|
||||||
|
return {
|
||||||
|
"assignment_id": a.id,
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc.name if loc else None,
|
||||||
|
# Soft-removal indicator so the UI can render a "(removed)"
|
||||||
|
# badge next to historical attributions whose location is no
|
||||||
|
# longer actively monitored.
|
||||||
|
"location_removed_at": (loc.removed_at.isoformat()
|
||||||
|
if loc and loc.removed_at else None),
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"project_name": proj.name if proj else None,
|
||||||
|
"assigned_at": _iso_utc(a.assigned_at),
|
||||||
|
"assigned_until": _iso_utc(a.assigned_until),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Fetch all events for this serial in one shot.
|
||||||
|
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
|
||||||
|
events = await _fetch_events_for_serial(
|
||||||
|
client,
|
||||||
|
serial=unit_id,
|
||||||
|
from_dt=from_dt or datetime(1970, 1, 1),
|
||||||
|
to_dt=to_dt or now,
|
||||||
|
false_trigger=false_trigger,
|
||||||
|
limit=_SFM_FETCH_CEILING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. For each event, walk the assignment list and find the first
|
||||||
|
# overlapping window. O(N * M) but both are small in practice.
|
||||||
|
for ev in events:
|
||||||
|
ts_str = ev.get("timestamp")
|
||||||
|
if not ts_str:
|
||||||
|
ev["attribution"] = None
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# SFM returns ISO with "T" separator; tolerate both.
|
||||||
|
ts = datetime.fromisoformat(ts_str.replace(" ", "T"))
|
||||||
|
except ValueError:
|
||||||
|
ev["attribution"] = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched: Optional[UnitAssignment] = None
|
||||||
|
for a in assignments:
|
||||||
|
a_end = a.assigned_until or now
|
||||||
|
if a.assigned_at <= ts <= a_end:
|
||||||
|
matched = a
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched is not None:
|
||||||
|
ev["attribution"] = _attr_dict(matched)
|
||||||
|
else:
|
||||||
|
ev["attribution"] = None
|
||||||
|
# Find the nearest assignment (chronologically) for diagnostic.
|
||||||
|
if assignments:
|
||||||
|
nearest = min(
|
||||||
|
assignments,
|
||||||
|
key=lambda a: min(
|
||||||
|
abs((ts - a.assigned_at).total_seconds()),
|
||||||
|
abs((ts - (a.assigned_until or now)).total_seconds()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Signed delta in days from the nearest boundary
|
||||||
|
# (negative = event BEFORE that boundary).
|
||||||
|
if ts < nearest.assigned_at:
|
||||||
|
delta_seconds = (ts - nearest.assigned_at).total_seconds()
|
||||||
|
elif ts > (nearest.assigned_until or now):
|
||||||
|
delta_seconds = (ts - (nearest.assigned_until or now)).total_seconds()
|
||||||
|
else:
|
||||||
|
delta_seconds = 0
|
||||||
|
ev["nearest_assignment"] = {
|
||||||
|
**_attr_dict(nearest),
|
||||||
|
"delta_days": round(delta_seconds / 86400, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Apply bucket filter.
|
||||||
|
if bucket == "attributed":
|
||||||
|
filtered = [e for e in events if e.get("attribution") is not None]
|
||||||
|
elif bucket == "unattributed":
|
||||||
|
filtered = [e for e in events if e.get("attribution") is None]
|
||||||
|
else:
|
||||||
|
filtered = events
|
||||||
|
|
||||||
|
filtered.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||||
|
total_count = len(filtered)
|
||||||
|
capped = filtered[:limit]
|
||||||
|
|
||||||
|
# 5. Stats: compute over the ENTIRE event set (not the filtered bucket)
|
||||||
|
# so the unattributed_count tile is always meaningful regardless of
|
||||||
|
# which bucket the operator has selected.
|
||||||
|
base_stats = _compute_stats(events)
|
||||||
|
unattributed_count = sum(
|
||||||
|
1 for e in events if e.get("attribution") is None
|
||||||
|
)
|
||||||
|
base_stats["unattributed_count"] = unattributed_count
|
||||||
|
|
||||||
|
return {
|
||||||
|
"events": capped,
|
||||||
|
"count": total_count,
|
||||||
|
"stats": base_stats,
|
||||||
|
"assignments_total": len(assignments),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Project-level roll-up (aggregates across all vibration locations) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
async def vibration_summary_for_project(
|
||||||
|
db: Session,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
from_dt: Optional[datetime] = None,
|
||||||
|
to_dt: Optional[datetime] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Aggregate SFM events across every vibration location in a project.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"project_id": str,
|
||||||
|
"total_events": int,
|
||||||
|
"peak_pvs": float | None,
|
||||||
|
"peak_pvs_at": ISO timestamp | None,
|
||||||
|
"peak_pvs_location_id": str | None,
|
||||||
|
"peak_pvs_location_name": str | None,
|
||||||
|
"last_event": ISO timestamp | None,
|
||||||
|
"false_trigger_count": int,
|
||||||
|
"per_location": [
|
||||||
|
{"location_id", "location_name", "event_count",
|
||||||
|
"peak_pvs", "last_event"},
|
||||||
|
... # sorted by event_count DESC
|
||||||
|
],
|
||||||
|
"vibration_location_count": int,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
locations = (
|
||||||
|
db.query(MonitoringLocation)
|
||||||
|
.filter(MonitoringLocation.project_id == project_id)
|
||||||
|
.filter(MonitoringLocation.location_type == "vibration")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not locations:
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"total_events": 0,
|
||||||
|
"peak_pvs": None,
|
||||||
|
"peak_pvs_at": None,
|
||||||
|
"peak_pvs_location_id": None,
|
||||||
|
"peak_pvs_location_name": None,
|
||||||
|
"last_event": None,
|
||||||
|
"false_trigger_count": 0,
|
||||||
|
"per_location": [],
|
||||||
|
"vibration_location_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fan out across locations. Each call internally fans out across that
|
||||||
|
# location's UnitAssignment rows, so this is a nested fan-out. Both
|
||||||
|
# tiers happen concurrently because asyncio.gather + httpx pool.
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
events_for_location(
|
||||||
|
db,
|
||||||
|
loc.id,
|
||||||
|
from_dt=from_dt,
|
||||||
|
to_dt=to_dt,
|
||||||
|
false_trigger=None,
|
||||||
|
limit=1, # We only need stats; events list itself is ignored.
|
||||||
|
)
|
||||||
|
for loc in locations
|
||||||
|
),
|
||||||
|
return_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
per_location: list[dict] = []
|
||||||
|
total_events = 0
|
||||||
|
peak_pvs = None
|
||||||
|
peak_pvs_at = None
|
||||||
|
peak_pvs_location_id = None
|
||||||
|
peak_pvs_location_name = None
|
||||||
|
last_event = None
|
||||||
|
false_trigger_count = 0
|
||||||
|
|
||||||
|
for loc, res in zip(locations, results):
|
||||||
|
st = res.get("stats", {}) or {}
|
||||||
|
ec = st.get("event_count", 0) or 0
|
||||||
|
total_events += ec
|
||||||
|
false_trigger_count += st.get("false_trigger_count", 0) or 0
|
||||||
|
|
||||||
|
ev_last = st.get("last_event")
|
||||||
|
if ev_last and (last_event is None or ev_last > last_event):
|
||||||
|
last_event = ev_last
|
||||||
|
|
||||||
|
ev_peak = st.get("peak_pvs")
|
||||||
|
if ev_peak is not None and (peak_pvs is None or ev_peak > peak_pvs):
|
||||||
|
peak_pvs = ev_peak
|
||||||
|
peak_pvs_at = st.get("peak_pvs_at")
|
||||||
|
peak_pvs_location_id = loc.id
|
||||||
|
peak_pvs_location_name = loc.name
|
||||||
|
|
||||||
|
per_location.append({
|
||||||
|
"location_id": loc.id,
|
||||||
|
"location_name": loc.name,
|
||||||
|
"event_count": ec,
|
||||||
|
"peak_pvs": ev_peak,
|
||||||
|
"last_event": ev_last,
|
||||||
|
# Soft-removal state — UI can show a "(removed)" badge in the
|
||||||
|
# per-location list so operators see at a glance that a row's
|
||||||
|
# numbers are historical-only.
|
||||||
|
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"total_events": total_events,
|
||||||
|
"peak_pvs": peak_pvs,
|
||||||
|
"peak_pvs_at": peak_pvs_at,
|
||||||
|
"peak_pvs_location_id": peak_pvs_location_id,
|
||||||
|
"peak_pvs_location_name": peak_pvs_location_name,
|
||||||
|
"last_event": last_event,
|
||||||
|
"false_trigger_count": false_trigger_count,
|
||||||
|
"per_location": per_location,
|
||||||
|
"vibration_location_count": len(locations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stats helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_stats() -> dict:
|
||||||
|
return {
|
||||||
|
"event_count": 0,
|
||||||
|
"peak_pvs": None,
|
||||||
|
"peak_pvs_at": None,
|
||||||
|
"peak_pvs_serial": None,
|
||||||
|
"last_event": None,
|
||||||
|
"false_trigger_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_stats(events: list[dict]) -> dict:
|
||||||
|
"""Roll up summary stats from a merged event list. Cheap O(N) pass.
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
peak_pvs = None
|
||||||
|
peak_pvs_at = None
|
||||||
|
peak_pvs_serial = None
|
||||||
|
last_event = None
|
||||||
|
false_trigger_count = 0
|
||||||
|
|
||||||
|
for ev in events:
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
"event_count": len(events),
|
||||||
|
"peak_pvs": peak_pvs,
|
||||||
|
"peak_pvs_at": peak_pvs_at,
|
||||||
|
"peak_pvs_serial": peak_pvs_serial,
|
||||||
|
"last_event": last_event,
|
||||||
|
"false_trigger_count": false_trigger_count,
|
||||||
|
}
|
||||||
+152
-12
@@ -1,9 +1,77 @@
|
|||||||
from datetime import datetime, timezone
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.database import get_db_session
|
from backend.database import get_db_session
|
||||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
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):
|
def ensure_utc(dt):
|
||||||
if dt is None:
|
if dt is None:
|
||||||
@@ -69,6 +137,11 @@ def emit_status_snapshot():
|
|||||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
ignored = {i.id for i in db.query(IgnoredUnit).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 = {}
|
units = {}
|
||||||
|
|
||||||
# --- Merge roster entries first ---
|
# --- Merge roster entries first ---
|
||||||
@@ -80,29 +153,69 @@ def emit_status_snapshot():
|
|||||||
age = "N/A"
|
age = "N/A"
|
||||||
last_seen = None
|
last_seen = None
|
||||||
fname = ""
|
fname = ""
|
||||||
|
elif r.out_for_calibration:
|
||||||
|
# Out for calibration units get separated later
|
||||||
|
status = "Out for Calibration"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
|
elif getattr(r, 'allocated', False) and not r.deployed:
|
||||||
|
# Allocated: staged for an upcoming job, not yet physically deployed
|
||||||
|
status = "Allocated"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
else:
|
else:
|
||||||
if e:
|
device_type = r.device_type or "seismograph"
|
||||||
last_seen = ensure_utc(e.last_seen)
|
emitter_last_seen = ensure_utc(e.last_seen) if e else None
|
||||||
# RECALCULATE status based on current time, not stored value
|
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)
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
age = format_age(last_seen)
|
age = format_age(last_seen)
|
||||||
fname = e.last_file
|
|
||||||
else:
|
else:
|
||||||
# Rostered but no emitter data
|
|
||||||
status = "Missing"
|
status = "Missing"
|
||||||
last_seen = None
|
|
||||||
age = "N/A"
|
age = "N/A"
|
||||||
fname = ""
|
|
||||||
|
|
||||||
units[unit_id] = {
|
units[unit_id] = {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
"status": status,
|
"status": status,
|
||||||
"age": age,
|
"age": age,
|
||||||
"last": last_seen.isoformat() if last_seen else None,
|
"last": last_seen.isoformat() if last_seen else None,
|
||||||
|
"last_seen_source": last_seen_source,
|
||||||
|
"sfm_reachable": sfm_reachable,
|
||||||
"fname": fname,
|
"fname": fname,
|
||||||
"deployed": r.deployed,
|
"deployed": r.deployed,
|
||||||
"note": r.note or "",
|
"note": r.note or "",
|
||||||
"retired": r.retired,
|
"retired": r.retired,
|
||||||
|
"out_for_calibration": r.out_for_calibration or False,
|
||||||
|
"allocated": getattr(r, 'allocated', False) or False,
|
||||||
|
"allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "",
|
||||||
# Device type and type-specific fields
|
# Device type and type-specific fields
|
||||||
"device_type": r.device_type or "seismograph",
|
"device_type": r.device_type or "seismograph",
|
||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
@@ -121,18 +234,30 @@ def emit_status_snapshot():
|
|||||||
# --- Add unexpected emitter-only units ---
|
# --- Add unexpected emitter-only units ---
|
||||||
for unit_id, e in emitters.items():
|
for unit_id, e in emitters.items():
|
||||||
if unit_id not in roster:
|
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
|
# RECALCULATE status for unknown units too
|
||||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
units[unit_id] = {
|
units[unit_id] = {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
"status": status,
|
"status": status,
|
||||||
"age": format_age(last_seen),
|
"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,
|
"fname": e.last_file,
|
||||||
"deployed": False, # default
|
"deployed": False, # default
|
||||||
"note": "",
|
"note": "",
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
|
"allocated": False,
|
||||||
|
"allocated_to_project_id": "",
|
||||||
# Device type and type-specific fields (defaults for unknown units)
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
"device_type": "seismograph", # default
|
"device_type": "seismograph", # default
|
||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
@@ -174,17 +299,23 @@ def emit_status_snapshot():
|
|||||||
unit_data["status"] = paired_unit.get("status", "Missing")
|
unit_data["status"] = paired_unit.get("status", "Missing")
|
||||||
unit_data["age"] = paired_unit.get("age", "N/A")
|
unit_data["age"] = paired_unit.get("age", "N/A")
|
||||||
unit_data["last"] = paired_unit.get("last")
|
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
|
unit_data["derived_from"] = paired_unit_id
|
||||||
|
|
||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
if not u["retired"] and u["deployed"] and uid not in ignored
|
if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
benched_units = {
|
benched_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
if not u["retired"] and not u["deployed"] and uid not in ignored
|
if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
allocated_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
retired_units = {
|
retired_units = {
|
||||||
@@ -192,6 +323,11 @@ def emit_status_snapshot():
|
|||||||
if u["retired"]
|
if u["retired"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out_for_calibration_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if u["out_for_calibration"]
|
||||||
|
}
|
||||||
|
|
||||||
# Unknown units - emitters that aren't in the roster and aren't ignored
|
# Unknown units - emitters that aren't in the roster and aren't ignored
|
||||||
unknown_units = {
|
unknown_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
@@ -203,13 +339,17 @@ def emit_status_snapshot():
|
|||||||
"units": units,
|
"units": units,
|
||||||
"active": active_units,
|
"active": active_units,
|
||||||
"benched": benched_units,
|
"benched": benched_units,
|
||||||
|
"allocated": allocated_units,
|
||||||
"retired": retired_units,
|
"retired": retired_units,
|
||||||
|
"out_for_calibration": out_for_calibration_units,
|
||||||
"unknown": unknown_units,
|
"unknown": unknown_units,
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": len(active_units) + len(benched_units),
|
"total": len(active_units) + len(benched_units) + len(allocated_units),
|
||||||
"active": len(active_units),
|
"active": len(active_units),
|
||||||
"benched": len(benched_units),
|
"benched": len(benched_units),
|
||||||
|
"allocated": len(allocated_units),
|
||||||
"retired": len(retired_units),
|
"retired": len(retired_units),
|
||||||
|
"out_for_calibration": len(out_for_calibration_units),
|
||||||
"unknown": len(unknown_units),
|
"unknown": len(unknown_units),
|
||||||
# Status counts only for deployed units (active_units)
|
# Status counts only for deployed units (active_units)
|
||||||
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -60,11 +60,31 @@ def jinja_same_date(dt1, dt2) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_log_tail_display(s):
|
||||||
|
"""Jinja filter: decode a JSON-encoded log tail array into a plain-text string."""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
lines = _json.loads(s)
|
||||||
|
if isinstance(lines, list):
|
||||||
|
return "\n".join(str(l) for l in lines)
|
||||||
|
return str(s)
|
||||||
|
except Exception:
|
||||||
|
return str(s)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_local_datetime_input(dt):
|
||||||
|
"""Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for <input type='datetime-local'>."""
|
||||||
|
return format_local_datetime(dt, "%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
|
||||||
# Register Jinja filters and globals
|
# Register Jinja filters and globals
|
||||||
templates.env.filters["local_datetime"] = jinja_local_datetime
|
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||||
templates.env.filters["local_time"] = jinja_local_time
|
templates.env.filters["local_time"] = jinja_local_time
|
||||||
templates.env.filters["local_date"] = jinja_local_date
|
templates.env.filters["local_date"] = jinja_local_date
|
||||||
|
templates.env.filters["local_datetime_input"] = jinja_local_datetime_input
|
||||||
templates.env.filters["fromjson"] = jinja_fromjson
|
templates.env.filters["fromjson"] = jinja_fromjson
|
||||||
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
||||||
templates.env.globals["get_user_timezone"] = get_user_timezone
|
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||||
templates.env.globals["same_date"] = jinja_same_date
|
templates.env.globals["same_date"] = jinja_same_date
|
||||||
|
templates.env.filters["log_tail_display"] = jinja_log_tail_display
|
||||||
|
|||||||
+22
-3
@@ -1,9 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# --- TERRA-VIEW PRODUCTION ---
|
|
||||||
terra-view:
|
terra-view:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view
|
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -12,9 +10,11 @@ services:
|
|||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||||
|
- SFM_BASE_URL=http://sfm:8200
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
|
- sfm
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -29,7 +29,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../slmm
|
context: ../slmm
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: slmm
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- ../slmm/data:/app/data
|
- ../slmm/data:/app/data
|
||||||
@@ -47,5 +46,25 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
# --- SFM (Seismo Fleet Manager) ---
|
||||||
|
sfm:
|
||||||
|
build:
|
||||||
|
context: ../seismo-relay
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8200:8200"
|
||||||
|
volumes:
|
||||||
|
- ../seismo-relay/sfm/data:/app/sfm/data
|
||||||
|
- ../seismo-relay/bridges/captures:/app/bridges/captures
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- PORT=8200
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Migration: add watcher_agents table.
|
||||||
|
|
||||||
|
Safe to run multiple times (idempotent).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "data", "seismo.db")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
con = sqlite3.connect(DB_PATH)
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS watcher_agents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
version TEXT,
|
||||||
|
last_seen DATETIME,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
ip_address TEXT,
|
||||||
|
log_tail TEXT,
|
||||||
|
update_pending INTEGER NOT NULL DEFAULT 0,
|
||||||
|
update_version TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
print("Migration complete: watcher_agents table ready.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
Regular → Executable
@@ -8,3 +8,4 @@ aiofiles==23.2.1
|
|||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
|
rapidfuzz==3.10.1
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import sys
|
|||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///data/sfm.db"
|
DATABASE_URL = "sqlite:///data/seismo_fleet.db"
|
||||||
|
|
||||||
def rename_unit(old_id: str, new_id: str):
|
def rename_unit(old_id: str, new_id: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,711 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Deployment History - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.dh-calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 1280px) { .dh-calendar-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
@media (max-width: 768px) { .dh-calendar-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 480px) { .dh-calendar-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.dh-month-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.dark .dh-month-card { background: rgb(30 41 59); }
|
||||||
|
|
||||||
|
.dh-day-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.dh-day-header {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.dark .dh-day-header { color: #9ca3af; }
|
||||||
|
|
||||||
|
.dh-day-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding-top: 2px;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dh-day-cell:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.dh-day-cell.empty {
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.dh-day-cell.empty:hover { transform: none; }
|
||||||
|
.dh-day-cell.today {
|
||||||
|
ring-color: #f48b1c;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b84a12;
|
||||||
|
}
|
||||||
|
.dark .dh-day-cell {
|
||||||
|
background-color: rgba(55, 65, 81, 0.5);
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.dh-day-cell .dh-day-num {
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dh-day-cell .dh-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
margin: 2px 2px 0 2px;
|
||||||
|
}
|
||||||
|
.dh-bar {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6 flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Deployment History</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Where every unit has been — actual assignment windows, color-coded by project.
|
||||||
|
For future / planned deployments use the <a href="/fleet-calendar" class="text-seismo-orange hover:text-seismo-burgundy">Job Planner</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View tabs: Calendar | Gantt (by project) | By Unit (gantt by unit) -->
|
||||||
|
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
|
||||||
|
<button id="dh-tab-calendar" onclick="switchDhView('calendar')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
|
||||||
|
Calendar
|
||||||
|
</button>
|
||||||
|
<button id="dh-tab-gantt" onclick="switchDhView('gantt')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Gantt by Project
|
||||||
|
</button>
|
||||||
|
<button id="dh-tab-byunit" onclick="switchDhView('byunit')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Gantt by Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI strip -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||||
|
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }}</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_active_units }} unique units</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window</span>
|
||||||
|
</div>
|
||||||
|
{% if calendar.projects %}
|
||||||
|
<details class="bg-white dark:bg-slate-800 rounded-lg shadow px-4 py-2">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 select-none">Project legend</summary>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
{% for p in calendar.projects %}
|
||||||
|
<a href="/projects/{{ p.id }}"
|
||||||
|
class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-seismo-orange">
|
||||||
|
<span class="w-3 h-1.5 rounded-full flex-shrink-0" style="background-color: {{ p.color }};"></span>
|
||||||
|
{{ p.name }}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">·</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ p.assignment_count }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Calendar view ─── -->
|
||||||
|
<div id="dh-view-calendar">
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
{% if calendar.projects %}
|
||||||
|
<div class="dh-calendar-grid mb-6">
|
||||||
|
{% for month_data in calendar.months %}
|
||||||
|
<div class="dh-month-card">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
|
||||||
|
{{ month_data.short_name }} '{{ month_data.year_short }}
|
||||||
|
</h3>
|
||||||
|
<div class="dh-day-grid">
|
||||||
|
<div class="dh-day-header">S</div>
|
||||||
|
<div class="dh-day-header">M</div>
|
||||||
|
<div class="dh-day-header">T</div>
|
||||||
|
<div class="dh-day-header">W</div>
|
||||||
|
<div class="dh-day-header">T</div>
|
||||||
|
<div class="dh-day-header">F</div>
|
||||||
|
<div class="dh-day-header">S</div>
|
||||||
|
|
||||||
|
{# Sunday-first alignment: shift Monday=0 → Sunday=0 #}
|
||||||
|
{% set first_offset = (month_data.first_weekday + 1) % 7 %}
|
||||||
|
{% for i in range(first_offset) %}
|
||||||
|
<div class="dh-day-cell empty"></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for day_num in range(1, month_data.num_days + 1) %}
|
||||||
|
{% set date_str = '%04d-%02d-%02d' | format(month_data.year, month_data.month, day_num) %}
|
||||||
|
{% set is_today = date_str == today %}
|
||||||
|
{% set day_proj_ids = month_data.active_days.get(day_num, []) %}
|
||||||
|
<div class="dh-day-cell{% if is_today %} today ring-2 ring-seismo-orange{% endif %}"
|
||||||
|
onclick="openDhDay('{{ date_str }}')"
|
||||||
|
title="{{ date_str }} — {{ day_proj_ids | length }} project{{ '' if day_proj_ids | length == 1 else 's' }}">
|
||||||
|
<span class="dh-day-num">{{ day_num }}</span>
|
||||||
|
{% if day_proj_ids %}
|
||||||
|
<span class="dh-bars">
|
||||||
|
{% for pid in day_proj_ids[:4] %}
|
||||||
|
{% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %}
|
||||||
|
{% if p %}
|
||||||
|
<span class="dh-bar" style="background-color: {{ p.color }};"
|
||||||
|
title="{{ p.name }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if day_proj_ids | length > 4 %}
|
||||||
|
<span class="text-[8px] text-gray-500 leading-none">+{{ day_proj_ids | length - 4 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Month navigation -->
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Previous month">
|
||||||
|
<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="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||||
|
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
||||||
|
</span>
|
||||||
|
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Next month">
|
||||||
|
<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 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/tools/deployment-history"
|
||||||
|
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||||
|
Recent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" 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"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No deployments in this window.</p>
|
||||||
|
<p class="text-xs mt-1">Try the navigation buttons below to look at a different range.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div> {# /#dh-view-calendar #}
|
||||||
|
|
||||||
|
<!-- ─── Gantt view ─── -->
|
||||||
|
<div id="dh-view-gantt" class="hidden">
|
||||||
|
{% if calendar.projects %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
One row per project. Each bar is one assignment window (unit at a location).
|
||||||
|
Hover for details, click to open the project.
|
||||||
|
Active deployments end at "now" with a small white tip.
|
||||||
|
</p>
|
||||||
|
<svg id="dh-gantt-svg" preserveAspectRatio="none"
|
||||||
|
style="width: 100%; min-width: 800px;"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Same nav as calendar view, repeated here so the Gantt view has its own. -->
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#gantt"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Previous month">
|
||||||
|
<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="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||||
|
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
||||||
|
</span>
|
||||||
|
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#gantt"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Next month">
|
||||||
|
<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 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/tools/deployment-history#gantt"
|
||||||
|
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||||
|
Recent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-sm">No deployments in this window.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div> {# /#dh-view-gantt #}
|
||||||
|
|
||||||
|
<!-- ─── Gantt-by-Unit view ─── -->
|
||||||
|
<div id="dh-view-byunit" class="hidden">
|
||||||
|
{% if calendar.units %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
One row per seismograph that had ≥1 assignment in this window.
|
||||||
|
Bars are colored by <strong>project</strong>, so an operator can read where each unit travelled
|
||||||
|
across jobs. Active deployments end at "now" with a small white tip.
|
||||||
|
Click a unit ID to open its detail page; click a bar to open the bar's project.
|
||||||
|
</p>
|
||||||
|
<svg id="dh-byunit-svg" preserveAspectRatio="none"
|
||||||
|
style="width: 100%; min-width: 800px;"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#byunit"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Previous month">
|
||||||
|
<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="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||||
|
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
||||||
|
</span>
|
||||||
|
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#byunit"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Next month">
|
||||||
|
<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 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/tools/deployment-history#byunit"
|
||||||
|
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||||
|
Recent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-sm">No unit deployments in this window.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div> {# /#dh-view-byunit #}
|
||||||
|
|
||||||
|
<!-- Day-detail side panel -->
|
||||||
|
<div id="dh-day-panel-backdrop"
|
||||||
|
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
|
||||||
|
onclick="closeDhDayPanel()"></div>
|
||||||
|
<div id="dh-day-panel"
|
||||||
|
class="fixed top-0 right-0 h-screen w-full max-w-md bg-white dark:bg-slate-800 shadow-2xl z-50 hidden transform translate-x-full transition-transform duration-300 overflow-y-auto">
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" id="dh-day-panel-title">Deployments on…</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400" id="dh-day-panel-subtitle"></p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeDhDayPanel()" class="text-gray-400 hover:text-gray-700 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>
|
||||||
|
<div id="dh-day-panel-body" class="p-5 space-y-3">
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function _dhEsc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDhDay(dateStr) {
|
||||||
|
const panel = document.getElementById('dh-day-panel');
|
||||||
|
const backdrop = document.getElementById('dh-day-panel-backdrop');
|
||||||
|
const title = document.getElementById('dh-day-panel-title');
|
||||||
|
const sub = document.getElementById('dh-day-panel-subtitle');
|
||||||
|
const body = document.getElementById('dh-day-panel-body');
|
||||||
|
|
||||||
|
title.textContent = 'Deployments on ' + dateStr;
|
||||||
|
sub.textContent = '';
|
||||||
|
body.innerHTML = `<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
|
||||||
|
Loading…
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
backdrop.classList.remove('hidden');
|
||||||
|
requestAnimationFrame(() => panel.classList.remove('translate-x-full'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/deployment-history/day?target_date=${encodeURIComponent(dateStr)}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const d = await r.json();
|
||||||
|
renderDhDayPanel(d, dateStr);
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `<p class="text-sm text-red-500">Failed to load: ${_dhEsc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDhDayPanel(d, dateStr) {
|
||||||
|
const sub = document.getElementById('dh-day-panel-subtitle');
|
||||||
|
const body = document.getElementById('dh-day-panel-body');
|
||||||
|
|
||||||
|
sub.textContent = `${d.count} active deployment${d.count === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
if (d.count === 0) {
|
||||||
|
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No active deployments on this date.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by project for cleaner display.
|
||||||
|
const byProj = {};
|
||||||
|
for (const dep of d.deployments) {
|
||||||
|
const key = dep.project_id || '_none';
|
||||||
|
if (!byProj[key]) byProj[key] = { name: dep.project_name, color: dep.project_color, items: [] };
|
||||||
|
byProj[key].items.push(dep);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const pid of Object.keys(byProj)) {
|
||||||
|
const proj = byProj[pid];
|
||||||
|
html += `<div class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href="/projects/${_dhEsc(pid)}" class="flex items-center gap-2 font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||||
|
<span class="w-3 h-3 rounded-full" style="background-color: ${proj.color};"></span>
|
||||||
|
${_dhEsc(proj.name)}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">${proj.items.length} unit${proj.items.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 ml-5">`;
|
||||||
|
for (const dep of proj.items) {
|
||||||
|
const activeBadge = dep.is_active
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||||
|
: '';
|
||||||
|
const sourceTag = dep.source === 'metadata_backfill'
|
||||||
|
? '<span class="text-[10px] text-blue-600 dark:text-blue-400 italic">auto-backfilled</span>'
|
||||||
|
: '';
|
||||||
|
const windowEnd = dep.is_active ? 'present' : (dep.assigned_until || '').slice(0, 10);
|
||||||
|
const windowStart = (dep.assigned_at || '').slice(0, 10);
|
||||||
|
html += `<div class="flex items-center justify-between gap-2 py-1 text-sm">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/unit/${_dhEsc(dep.unit_id)}" class="font-mono font-medium text-seismo-orange hover:text-seismo-navy">${_dhEsc(dep.unit_id)}</a>
|
||||||
|
${activeBadge}
|
||||||
|
${sourceTag}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
📍 <a href="/projects/${_dhEsc(pid)}/nrl/${_dhEsc(dep.location_id)}" class="hover:text-seismo-orange">${_dhEsc(dep.location_name)}</a>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">${windowStart} → ${windowEnd}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += `</div></div>`;
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDhDayPanel() {
|
||||||
|
const panel = document.getElementById('dh-day-panel');
|
||||||
|
const backdrop = document.getElementById('dh-day-panel-backdrop');
|
||||||
|
panel.classList.add('translate-x-full');
|
||||||
|
setTimeout(() => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
backdrop.classList.add('hidden');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeDhDayPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tab switcher ────────────────────────────────────────────────────
|
||||||
|
const _DH_TABS = {
|
||||||
|
calendar: { view: 'dh-view-calendar', btn: 'dh-tab-calendar', hash: '', render: null },
|
||||||
|
gantt: { view: 'dh-view-gantt', btn: 'dh-tab-gantt', hash: '#gantt', render: () => renderDhGantt() },
|
||||||
|
byunit: { view: 'dh-view-byunit', btn: 'dh-tab-byunit', hash: '#byunit', render: () => renderDhByUnit() },
|
||||||
|
};
|
||||||
|
|
||||||
|
function switchDhView(which) {
|
||||||
|
const target = _DH_TABS[which] || _DH_TABS.calendar;
|
||||||
|
const activeCls = ['bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow'];
|
||||||
|
const dormantCls = ['text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white'];
|
||||||
|
// Hide all views, dormant-style all tabs, then activate the chosen one.
|
||||||
|
for (const key of Object.keys(_DH_TABS)) {
|
||||||
|
const t = _DH_TABS[key];
|
||||||
|
const v = document.getElementById(t.view);
|
||||||
|
const b = document.getElementById(t.btn);
|
||||||
|
if (v) v.classList.add('hidden');
|
||||||
|
if (b) { b.classList.remove(...activeCls); b.classList.add(...dormantCls); }
|
||||||
|
}
|
||||||
|
const v = document.getElementById(target.view);
|
||||||
|
const b = document.getElementById(target.btn);
|
||||||
|
if (v) v.classList.remove('hidden');
|
||||||
|
if (b) { b.classList.add(...activeCls); b.classList.remove(...dormantCls); }
|
||||||
|
// URL hash sync.
|
||||||
|
const wanted = target.hash;
|
||||||
|
const cur = window.location.hash || '';
|
||||||
|
if (cur !== wanted) {
|
||||||
|
history.replaceState(null, '', window.location.pathname + window.location.search + wanted);
|
||||||
|
}
|
||||||
|
if (target.render) target.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Land on the requested view if the URL hash matches.
|
||||||
|
const _initialHash = window.location.hash;
|
||||||
|
if (_initialHash === '#gantt' || _initialHash === '#byunit') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => switchDhView(_initialHash.slice(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gantt renderer ──────────────────────────────────────────────────
|
||||||
|
// Server-rendered data: per-project list with each project's bars
|
||||||
|
// (assignment windows clipped to the visible window).
|
||||||
|
const _dhProjects = {{ calendar.projects | tojson }};
|
||||||
|
const _dhWindowStart = {{ calendar.window.first_date | tojson }};
|
||||||
|
const _dhWindowEnd = {{ calendar.window.last_date | tojson }};
|
||||||
|
|
||||||
|
let _ganttRendered = false;
|
||||||
|
function renderDhGantt() {
|
||||||
|
if (_ganttRendered) return; // build once; data doesn't change after page load
|
||||||
|
_ganttRendered = true;
|
||||||
|
const svg = document.getElementById('dh-gantt-svg');
|
||||||
|
if (!svg) return;
|
||||||
|
if (!_dhProjects || _dhProjects.length === 0) {
|
||||||
|
svg.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||||||
|
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||||||
|
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
|
||||||
|
const todayColor = '#f48b1c';
|
||||||
|
|
||||||
|
// Geometry. The Gantt SVG has a left "labels" gutter and a right
|
||||||
|
// time-axis area. Width is fluid (matches container); height grows
|
||||||
|
// with the number of project rows.
|
||||||
|
const containerW = svg.parentElement.clientWidth || 1000;
|
||||||
|
const width = Math.max(containerW, 800);
|
||||||
|
const labelW = 220;
|
||||||
|
const padTop = 36; // room for month labels above
|
||||||
|
const padBottom = 16;
|
||||||
|
const rowH = 36;
|
||||||
|
const barH = 18;
|
||||||
|
const height = padTop + padBottom + _dhProjects.length * rowH;
|
||||||
|
|
||||||
|
const usableW = width - labelW - 8;
|
||||||
|
|
||||||
|
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
|
||||||
|
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
|
||||||
|
const tRange = tEnd - tStart;
|
||||||
|
const xFor = (d) => {
|
||||||
|
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
|
||||||
|
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Month gridlines + month labels along the top.
|
||||||
|
// Walk every 1st-of-month inside the window.
|
||||||
|
const monthsParts = [];
|
||||||
|
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
|
||||||
|
cursor.setUTCDate(1);
|
||||||
|
while (cursor.getTime() <= tEnd) {
|
||||||
|
const x = xFor(cursor);
|
||||||
|
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||||
|
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||||
|
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today marker.
|
||||||
|
const now = Date.now();
|
||||||
|
let todayMarker = '';
|
||||||
|
if (now >= tStart && now <= tEnd) {
|
||||||
|
const x = xFor(now);
|
||||||
|
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
|
||||||
|
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build rows.
|
||||||
|
const rowParts = [];
|
||||||
|
_dhProjects.forEach((proj, idx) => {
|
||||||
|
const y = padTop + idx * rowH;
|
||||||
|
// Alternating row background.
|
||||||
|
if (idx % 2 === 1) {
|
||||||
|
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
||||||
|
}
|
||||||
|
// Project label (clipped to gutter, clickable).
|
||||||
|
const labelText = proj.name.length > 28 ? proj.name.slice(0, 26) + '…' : proj.name;
|
||||||
|
rowParts.push(`<a href="/projects/${_dhEsc(proj.id)}" target="_top">
|
||||||
|
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
||||||
|
<circle cx="14" cy="${y + rowH / 2}" r="4" fill="${proj.color}"/>
|
||||||
|
<text x="24" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelColor}"
|
||||||
|
font-family="system-ui,sans-serif"
|
||||||
|
title="${_dhEsc(proj.name)}">
|
||||||
|
${_dhEsc(labelText)}
|
||||||
|
</text>
|
||||||
|
</a>`);
|
||||||
|
// Bars for each assignment in this project.
|
||||||
|
for (const bar of (proj.bars || [])) {
|
||||||
|
const x1 = xFor(bar.start);
|
||||||
|
const x2 = xFor(bar.end);
|
||||||
|
const barW = Math.max(x2 - x1, 2);
|
||||||
|
const by = y + (rowH - barH) / 2;
|
||||||
|
const tip = `${bar.unit_id} @ ${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
|
||||||
|
rowParts.push(`<g style="cursor: pointer;">
|
||||||
|
<title>${_dhEsc(tip)}</title>
|
||||||
|
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
||||||
|
rx="3"
|
||||||
|
fill="${proj.color}" opacity="${bar.is_active ? 1.0 : 0.75}"
|
||||||
|
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
|
||||||
|
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
|
||||||
|
</g>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gantt-by-Unit renderer ──────────────────────────────────────────
|
||||||
|
// Inverts the Gantt: rows = seismographs, bars = assignment windows
|
||||||
|
// colored by project (so an operator can read where each unit went
|
||||||
|
// across jobs). Shares the time-axis geometry with renderDhGantt().
|
||||||
|
const _dhUnits = {{ calendar.units | tojson }};
|
||||||
|
let _byunitRendered = false;
|
||||||
|
function renderDhByUnit() {
|
||||||
|
if (_byunitRendered) return;
|
||||||
|
_byunitRendered = true;
|
||||||
|
const svg = document.getElementById('dh-byunit-svg');
|
||||||
|
if (!svg) return;
|
||||||
|
if (!_dhUnits || _dhUnits.length === 0) {
|
||||||
|
svg.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||||||
|
const labelStrong = isDark ? '#e5e7eb' : '#111827';
|
||||||
|
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||||||
|
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
|
||||||
|
const todayColor = '#f48b1c';
|
||||||
|
|
||||||
|
const containerW = svg.parentElement.clientWidth || 1000;
|
||||||
|
const width = Math.max(containerW, 800);
|
||||||
|
const labelW = 160;
|
||||||
|
const padTop = 36;
|
||||||
|
const padBottom = 16;
|
||||||
|
const rowH = 32;
|
||||||
|
const barH = 16;
|
||||||
|
const height = padTop + padBottom + _dhUnits.length * rowH;
|
||||||
|
|
||||||
|
const usableW = width - labelW - 8;
|
||||||
|
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
|
||||||
|
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
|
||||||
|
const tRange = tEnd - tStart;
|
||||||
|
const xFor = (d) => {
|
||||||
|
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
|
||||||
|
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Month gridlines + labels (same as renderDhGantt).
|
||||||
|
const monthsParts = [];
|
||||||
|
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
|
||||||
|
cursor.setUTCDate(1);
|
||||||
|
while (cursor.getTime() <= tEnd) {
|
||||||
|
const x = xFor(cursor);
|
||||||
|
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||||
|
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||||
|
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let todayMarker = '';
|
||||||
|
if (now >= tStart && now <= tEnd) {
|
||||||
|
const x = xFor(now);
|
||||||
|
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
|
||||||
|
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowParts = [];
|
||||||
|
_dhUnits.forEach((unit, idx) => {
|
||||||
|
const y = padTop + idx * rowH;
|
||||||
|
if (idx % 2 === 1) {
|
||||||
|
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
||||||
|
}
|
||||||
|
// Unit label: opens /unit/{id} when clicked. Active-now dot.
|
||||||
|
const activeDot = unit.any_active
|
||||||
|
? `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="#22c55e"/>`
|
||||||
|
: `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="${labelColor}" opacity="0.4"/>`;
|
||||||
|
rowParts.push(`<a href="/unit/${_dhEsc(unit.id)}" target="_top">
|
||||||
|
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
||||||
|
${activeDot}
|
||||||
|
<text x="22" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelStrong}"
|
||||||
|
font-family="system-ui,sans-serif" font-weight="500">
|
||||||
|
${_dhEsc(unit.id)}
|
||||||
|
</text>
|
||||||
|
<text x="${labelW - 8}" y="${y + rowH / 2 + 4}" font-size="10" fill="${labelColor}"
|
||||||
|
text-anchor="end" font-family="system-ui,sans-serif">
|
||||||
|
${unit.assignment_count}
|
||||||
|
</text>
|
||||||
|
</a>`);
|
||||||
|
// Bars — colored by the bar's project (not the row).
|
||||||
|
for (const bar of (unit.bars || [])) {
|
||||||
|
const x1 = xFor(bar.start);
|
||||||
|
const x2 = xFor(bar.end);
|
||||||
|
const barW = Math.max(x2 - x1, 2);
|
||||||
|
const by = y + (rowH - barH) / 2;
|
||||||
|
const tip = `${bar.project_name}\n${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
|
||||||
|
rowParts.push(`<a href="/projects/${_dhEsc(bar.project_id)}" target="_top">
|
||||||
|
<g style="cursor: pointer;">
|
||||||
|
<title>${_dhEsc(tip)}</title>
|
||||||
|
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
||||||
|
rx="3"
|
||||||
|
fill="${bar.project_color}" opacity="${bar.is_active ? 1.0 : 0.75}"
|
||||||
|
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
|
||||||
|
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
|
||||||
|
</g>
|
||||||
|
</a>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
{% 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Args go in data-* attributes (not inline onclick) to avoid the quote
|
||||||
|
// collision when location names contain characters JSON.stringify quotes
|
||||||
|
// (e.g. anything with spaces/punctuation — basically every real name).
|
||||||
|
// _esc() escapes for HTML attribute context (entities for <>&"), then
|
||||||
|
// the browser decodes them when reading the dataset value.
|
||||||
|
dropdown.innerHTML = items.map((it, idx) => {
|
||||||
|
const cid = _esc(input.dataset.clusterId);
|
||||||
|
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"
|
||||||
|
data-cid="${cid}"
|
||||||
|
data-field-kind="${fieldKind}"
|
||||||
|
data-entity-id="${_esc(m.id)}"
|
||||||
|
data-entity-name="${_esc(m.name)}"
|
||||||
|
onmousedown="event.preventDefault()"
|
||||||
|
onclick="_typeaheadPickFromButton(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">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
|
||||||
|
${metaLine}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
return `<button type="button"
|
||||||
|
data-cid="${cid}"
|
||||||
|
data-field-kind="${fieldKind}"
|
||||||
|
data-entity-id=""
|
||||||
|
data-entity-name="${_esc(it.name)}"
|
||||||
|
onmousedown="event.preventDefault()"
|
||||||
|
onclick="_typeaheadPickFromButton(this)"
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trampoline — reads the click target's data-* attributes and forwards
|
||||||
|
// to onTypeaheadPick. Keeps the inline onclick attribute free of any
|
||||||
|
// string interpolation that could collide with HTML quoting.
|
||||||
|
function _typeaheadPickFromButton(btn) {
|
||||||
|
onTypeaheadPick(
|
||||||
|
null,
|
||||||
|
btn.dataset.cid,
|
||||||
|
btn.dataset.fieldKind,
|
||||||
|
btn.dataset.entityId || '',
|
||||||
|
btn.dataset.entityName || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,462 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Pending Deployments - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Pending Deployments</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Captures from the field waiting to be classified.
|
||||||
|
<a href="/deploy" class="text-seismo-orange hover:text-seismo-navy">Capture a new one →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter pills -->
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<button onclick="switchPdStatus('awaiting')" id="pd-tab-awaiting"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-seismo-orange text-white">
|
||||||
|
Awaiting <span id="pd-count-awaiting" class="ml-1 text-xs opacity-80"></span>
|
||||||
|
</button>
|
||||||
|
<button onclick="switchPdStatus('assigned')" id="pd-tab-assigned"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Assigned
|
||||||
|
</button>
|
||||||
|
<button onclick="switchPdStatus('cancelled')" id="pd-tab-cancelled"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Cancelled
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pd-list" class="space-y-4">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Classify modal -->
|
||||||
|
<div id="classify-modal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="min-height: 480px;">
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Classify pending deployment</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span id="classify-unit-label" class="font-mono text-seismo-orange"></span>
|
||||||
|
captured at
|
||||||
|
<span id="classify-captured-at"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeClassifyModal()" class="text-gray-400 hover:text-gray-700 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>
|
||||||
|
|
||||||
|
<div class="p-5 space-y-5">
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="setClassifyMode('existing')" id="mode-existing"
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white">
|
||||||
|
Assign to existing location
|
||||||
|
</button>
|
||||||
|
<button onclick="setClassifyMode('new')" id="mode-new"
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Create new location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing mode: project + location pickers -->
|
||||||
|
<div id="classify-existing-pane" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||||
|
<select id="existing-project-select" onchange="onExistingProjectChange()"
|
||||||
|
class="mt-1 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">
|
||||||
|
<option value="">Loading projects…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
|
||||||
|
<select id="existing-location-select"
|
||||||
|
class="mt-1 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">
|
||||||
|
<option value="">Pick a project first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New mode: project (existing/new) + location name -->
|
||||||
|
<div id="classify-new-pane" class="hidden space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
|
<select id="new-project-select" onchange="onNewProjectMode()"
|
||||||
|
class="flex-1 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">
|
||||||
|
<option value="">— Create new project —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="new-project-name-wrap" class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">New project name</label>
|
||||||
|
<input id="new-project-name" type="text" placeholder="e.g. Carnegie Museum HVAC"
|
||||||
|
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">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Project type will be Vibration Monitoring.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location name</label>
|
||||||
|
<input id="new-location-name" type="text" placeholder="e.g. NE corner, near loading dock"
|
||||||
|
class="mt-1 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">
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" id="use-captured-coords" checked
|
||||||
|
class="rounded border-gray-300 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
Use the photo's GPS coords <span id="captured-coords-hint" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Assignment notes (optional)</label>
|
||||||
|
<textarea id="classify-notes" rows="2"
|
||||||
|
class="mt-1 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"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="classify-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
|
||||||
|
<button onclick="closeClassifyModal()"
|
||||||
|
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="classify-submit" onclick="submitClassify()"
|
||||||
|
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||||
|
Classify
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let _pdState = {
|
||||||
|
currentStatus: 'awaiting',
|
||||||
|
rows: [],
|
||||||
|
classifyingId: null,
|
||||||
|
classifyingPd: null,
|
||||||
|
classifyMode: 'existing', // 'existing' | 'new'
|
||||||
|
projectsCache: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtDateTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return iso.replace('T', ' ').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPdList() {
|
||||||
|
const list = document.getElementById('pd-list');
|
||||||
|
list.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/pending?status=${encodeURIComponent(_pdState.currentStatus)}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
_pdState.rows = data.pending_deployments || [];
|
||||||
|
renderPdList();
|
||||||
|
// Refresh awaiting count badge.
|
||||||
|
if (_pdState.currentStatus === 'awaiting') {
|
||||||
|
document.getElementById('pd-count-awaiting').textContent = data.count > 0 ? `(${data.count})` : '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Load failed: ${_esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPdList() {
|
||||||
|
const list = document.getElementById('pd-list');
|
||||||
|
if (_pdState.rows.length === 0) {
|
||||||
|
const blurb = {
|
||||||
|
awaiting: 'No captures awaiting classification.',
|
||||||
|
assigned: 'No assigned captures yet.',
|
||||||
|
cancelled: 'No cancelled captures.',
|
||||||
|
}[_pdState.currentStatus] || '';
|
||||||
|
list.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${blurb}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = _pdState.rows.map(pd => _renderPdCard(pd)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderPdCard(pd) {
|
||||||
|
const photoUrl = pd.photo_url || '';
|
||||||
|
const coords = pd.coordinates
|
||||||
|
? `<span class="font-mono text-xs">${_esc(pd.coordinates)}</span>`
|
||||||
|
: '<span class="text-xs italic text-gray-400">no GPS in photo</span>';
|
||||||
|
const noteHtml = pd.operator_note
|
||||||
|
? `<p class="text-xs text-gray-600 dark:text-gray-300 mt-1 italic">"${_esc(pd.operator_note)}"</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
let footerActions = '';
|
||||||
|
if (pd.status === 'awaiting') {
|
||||||
|
footerActions = `<div class="flex gap-2 mt-3">
|
||||||
|
<button onclick="openClassifyModal('${_esc(pd.id)}')"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg font-medium">
|
||||||
|
Classify
|
||||||
|
</button>
|
||||||
|
<button onclick="cancelPending('${_esc(pd.id)}')"
|
||||||
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (pd.status === 'assigned') {
|
||||||
|
footerActions = `<div class="mt-3 text-xs text-green-700 dark:text-green-400">
|
||||||
|
Promoted ${_fmtDateTime(pd.promoted_at)} → assignment <span class="font-mono">${_esc((pd.resulting_assignment_id || '').slice(0, 8))}…</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (pd.status === 'cancelled') {
|
||||||
|
footerActions = `<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex gap-4">
|
||||||
|
<div class="shrink-0 w-32 h-32 bg-gray-100 dark:bg-slate-900 rounded-lg overflow-hidden">
|
||||||
|
${photoUrl
|
||||||
|
? `<img src="${_esc(photoUrl)}" class="w-full h-full object-cover" alt="install">`
|
||||||
|
: `<div class="w-full h-full flex items-center justify-center text-gray-400 text-xs">(no photo)</div>`}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/unit/${_esc(pd.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">
|
||||||
|
${_esc(pd.unit_id)}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">captured ${_fmtDateTime(pd.captured_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${coords}</div>
|
||||||
|
${noteHtml}
|
||||||
|
${footerActions}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPdStatus(status) {
|
||||||
|
_pdState.currentStatus = status;
|
||||||
|
const tabs = { awaiting: 'pd-tab-awaiting', assigned: 'pd-tab-assigned', cancelled: 'pd-tab-cancelled' };
|
||||||
|
Object.entries(tabs).forEach(([k, id]) => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (k === status) {
|
||||||
|
btn.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
btn.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('bg-seismo-orange', 'text-white');
|
||||||
|
btn.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
loadPdList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Classify modal ──────────────────────────────────────────────────
|
||||||
|
async function openClassifyModal(pendingId) {
|
||||||
|
const pd = _pdState.rows.find(r => r.id === pendingId);
|
||||||
|
if (!pd) return;
|
||||||
|
_pdState.classifyingId = pendingId;
|
||||||
|
_pdState.classifyingPd = pd;
|
||||||
|
|
||||||
|
document.getElementById('classify-unit-label').textContent = pd.unit_id;
|
||||||
|
document.getElementById('classify-captured-at').textContent = _fmtDateTime(pd.captured_at);
|
||||||
|
document.getElementById('classify-notes').value = '';
|
||||||
|
document.getElementById('classify-error').classList.add('hidden');
|
||||||
|
document.getElementById('new-project-name').value = '';
|
||||||
|
document.getElementById('new-location-name').value = '';
|
||||||
|
|
||||||
|
// Coords hint for "use captured coords" checkbox.
|
||||||
|
const hint = document.getElementById('captured-coords-hint');
|
||||||
|
if (pd.coordinates) {
|
||||||
|
hint.textContent = `(${pd.coordinates})`;
|
||||||
|
document.getElementById('use-captured-coords').checked = true;
|
||||||
|
document.getElementById('use-captured-coords').disabled = false;
|
||||||
|
} else {
|
||||||
|
hint.textContent = '(no GPS in photo — uncheck unless you want a placeholder)';
|
||||||
|
document.getElementById('use-captured-coords').checked = false;
|
||||||
|
document.getElementById('use-captured-coords').disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClassifyMode('existing');
|
||||||
|
|
||||||
|
document.getElementById('classify-modal').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load projects if not cached.
|
||||||
|
if (!_pdState.projectsCache) {
|
||||||
|
await _loadProjects();
|
||||||
|
}
|
||||||
|
_populateProjectSelects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeClassifyModal() {
|
||||||
|
document.getElementById('classify-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadProjects() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/projects/list');
|
||||||
|
const data = r.ok ? await r.json() : { projects: [] };
|
||||||
|
// Endpoint shape varies; tolerate either { projects: [...] } or a flat array.
|
||||||
|
_pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
} catch (e) {
|
||||||
|
_pdState.projectsCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _populateProjectSelects() {
|
||||||
|
// Sort active projects first, then alphabetical.
|
||||||
|
const projs = (_pdState.projectsCache || []).slice().sort((a, b) => {
|
||||||
|
if ((a.status || '') !== (b.status || '')) {
|
||||||
|
if (a.status === 'active') return -1;
|
||||||
|
if (b.status === 'active') return 1;
|
||||||
|
}
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingSel = document.getElementById('existing-project-select');
|
||||||
|
existingSel.innerHTML = '<option value="">— Pick a project —</option>' + projs.map(p =>
|
||||||
|
`<option value="${_esc(p.id)}">${_esc(p.name)}${p.status && p.status !== 'active' ? ` (${p.status})` : ''}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const newSel = document.getElementById('new-project-select');
|
||||||
|
newSel.innerHTML = '<option value="">— Create new project —</option>' + projs.map(p =>
|
||||||
|
`<option value="${_esc(p.id)}">${_esc(p.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onExistingProjectChange() {
|
||||||
|
const projectId = document.getElementById('existing-project-select').value;
|
||||||
|
const locSel = document.getElementById('existing-location-select');
|
||||||
|
if (!projectId) {
|
||||||
|
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locSel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations-json?location_type=vibration`);
|
||||||
|
const locs = await r.json();
|
||||||
|
if (!Array.isArray(locs) || locs.length === 0) {
|
||||||
|
locSel.innerHTML = '<option value="">(no locations — use Create new location instead)</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locSel.innerHTML = '<option value="">— Pick a location —</option>' + locs.map(l =>
|
||||||
|
`<option value="${_esc(l.id)}">${_esc(l.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
} catch (e) {
|
||||||
|
locSel.innerHTML = `<option value="">Load failed: ${_esc(e.message)}</option>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setClassifyMode(mode) {
|
||||||
|
_pdState.classifyMode = mode;
|
||||||
|
const ex = document.getElementById('mode-existing');
|
||||||
|
const nw = document.getElementById('mode-new');
|
||||||
|
document.getElementById('classify-existing-pane').classList.toggle('hidden', mode !== 'existing');
|
||||||
|
document.getElementById('classify-new-pane').classList.toggle('hidden', mode !== 'new');
|
||||||
|
const activeCls = ['bg-seismo-orange', 'text-white'];
|
||||||
|
const dormantCls = ['bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600'];
|
||||||
|
if (mode === 'existing') {
|
||||||
|
ex.classList.add(...activeCls); ex.classList.remove(...dormantCls);
|
||||||
|
nw.classList.remove(...activeCls); nw.classList.add(...dormantCls);
|
||||||
|
} else {
|
||||||
|
nw.classList.add(...activeCls); nw.classList.remove(...dormantCls);
|
||||||
|
ex.classList.remove(...activeCls); ex.classList.add(...dormantCls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNewProjectMode() {
|
||||||
|
const sel = document.getElementById('new-project-select');
|
||||||
|
const wrap = document.getElementById('new-project-name-wrap');
|
||||||
|
wrap.classList.toggle('hidden', !!sel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitClassify() {
|
||||||
|
const btn = document.getElementById('classify-submit');
|
||||||
|
const errEl = document.getElementById('classify-error');
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
btn.disabled = true; btn.textContent = 'Classifying…';
|
||||||
|
|
||||||
|
const body = { notes: document.getElementById('classify-notes').value.trim() };
|
||||||
|
|
||||||
|
if (_pdState.classifyMode === 'existing') {
|
||||||
|
const locId = document.getElementById('existing-location-select').value;
|
||||||
|
if (!locId) {
|
||||||
|
errEl.textContent = 'Pick a location.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.location_id = locId;
|
||||||
|
} else {
|
||||||
|
const existingProj = document.getElementById('new-project-select').value;
|
||||||
|
const newName = document.getElementById('new-project-name').value.trim();
|
||||||
|
const locName = document.getElementById('new-location-name').value.trim();
|
||||||
|
if (!locName) {
|
||||||
|
errEl.textContent = 'Location name required.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingProj) {
|
||||||
|
body.project_id = existingProj;
|
||||||
|
} else {
|
||||||
|
if (!newName) {
|
||||||
|
errEl.textContent = 'Project name (or pick an existing project) required.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.project_name = newName;
|
||||||
|
body.project_type_id = 'vibration_monitoring';
|
||||||
|
}
|
||||||
|
body.location_name = locName;
|
||||||
|
body.use_captured_coords = document.getElementById('use-captured-coords').checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/pending/${_pdState.classifyingId}/promote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
closeClassifyModal();
|
||||||
|
loadPdList();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPending(pendingId) {
|
||||||
|
const reason = prompt('Cancel this capture?\n\nOptional reason:');
|
||||||
|
if (reason === null) return; // user hit Cancel on the prompt
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/pending/${pendingId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
loadPdList();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Cancel failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off the initial load.
|
||||||
|
loadPdList();
|
||||||
|
// Refresh awaiting count every 30s for the badge.
|
||||||
|
setInterval(() => {
|
||||||
|
if (_pdState.currentStatus === 'awaiting') loadPdList();
|
||||||
|
}, 30000);
|
||||||
|
</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,728 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Unit Swap</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Swap a vibration unit (and modem) at a monitoring location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/tools" class="text-xs text-seismo-orange hover:text-seismo-burgundy whitespace-nowrap">← Tools</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="flex items-center justify-between mb-4 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
<div id="swap-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-[10px]">1</span>
|
||||||
|
Project
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-2" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">2</span>
|
||||||
|
Location
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-3" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">3</span>
|
||||||
|
Unit
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-4" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">4</span>
|
||||||
|
Modem
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-5" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">5</span>
|
||||||
|
Confirm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Project picker -->
|
||||||
|
<div id="swap-step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which project?</span>
|
||||||
|
<input id="swap-project-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter by number, client, or name…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</label>
|
||||||
|
<div id="swap-project-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Location picker -->
|
||||||
|
<div id="swap-step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Project</p>
|
||||||
|
<p class="font-semibold text-seismo-orange truncate" id="swap-project-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Which location?</p>
|
||||||
|
<div id="swap-location-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: New unit picker -->
|
||||||
|
<div id="swap-step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Swapping at</p>
|
||||||
|
<p class="font-semibold text-seismo-orange truncate" id="swap-location-label">—</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Out: <span id="swap-old-unit-label" class="font-mono">—</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(2)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Incoming unit</span>
|
||||||
|
<input id="swap-unit-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter by serial…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only seismographs without an active assignment.</p>
|
||||||
|
</label>
|
||||||
|
<div id="swap-unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Modem decision -->
|
||||||
|
<div id="swap-step-4" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Incoming</p>
|
||||||
|
<p class="font-mono font-semibold text-seismo-orange" id="swap-new-unit-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(3)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" id="swap-modem-question">Modem?</p>
|
||||||
|
<div id="swap-modem-choice-list" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="swap-modem-picker-wrap" class="hidden">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Pick a modem</span>
|
||||||
|
<input id="swap-modem-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter modems…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</label>
|
||||||
|
<div id="swap-modem-list" class="max-h-60 overflow-y-auto space-y-1.5 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="swap-modem-next"
|
||||||
|
onclick="swapAfterModem()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5: Review + confirm -->
|
||||||
|
<div id="swap-step-5" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Review the swap</h3>
|
||||||
|
|
||||||
|
<dl class="text-sm space-y-2">
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Project</dt>
|
||||||
|
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-project">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||||
|
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-location">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Unit out</dt>
|
||||||
|
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-unit">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Unit in</dt>
|
||||||
|
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-unit">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Modem out</dt>
|
||||||
|
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-modem">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Modem in</dt>
|
||||||
|
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-modem">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes (optional)</span>
|
||||||
|
<textarea id="swap-notes" rows="2"
|
||||||
|
placeholder="Reason for swap, anything to remember…"
|
||||||
|
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="swap-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<button id="swap-confirm-btn"
|
||||||
|
onclick="swapConfirm()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
|
||||||
|
Confirm swap
|
||||||
|
</button>
|
||||||
|
<button onclick="swapGoToStep(4)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 6: Success + optional photo -->
|
||||||
|
<div id="swap-step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-14 h-14 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-3">Swap complete</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" id="swap-done-summary">—</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Add a photo of the new install?</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Optional. EXIF GPS will populate the unit's coordinates.</p>
|
||||||
|
<input id="swap-photo-input" type="file" accept="image/*" capture="environment"
|
||||||
|
onchange="swapOnPhotoPicked(event)"
|
||||||
|
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
|
||||||
|
<div id="swap-photo-preview-wrap" class="hidden mt-3">
|
||||||
|
<img id="swap-photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="">
|
||||||
|
</div>
|
||||||
|
<div id="swap-photo-error" class="hidden text-sm text-red-600 mt-2"></div>
|
||||||
|
<div id="swap-photo-status" class="hidden text-sm text-green-600 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button id="swap-photo-upload-btn"
|
||||||
|
onclick="swapUploadPhoto()"
|
||||||
|
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Upload photo
|
||||||
|
</button>
|
||||||
|
<button onclick="swapReset()"
|
||||||
|
class="px-4 py-3 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 font-medium">
|
||||||
|
Done — another swap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _swap = {
|
||||||
|
step: 1,
|
||||||
|
project: null, // { id, display, ... }
|
||||||
|
location: null, // { id, name, unit, modem }
|
||||||
|
new_unit: null, // { id, ... }
|
||||||
|
modem_action: null, // 'keep' | 'swap' | 'remove' | 'add' | 'none'
|
||||||
|
new_modem: null, // { id, ... }
|
||||||
|
all_projects: [],
|
||||||
|
all_units: [],
|
||||||
|
all_modems: [],
|
||||||
|
swap_result: null,
|
||||||
|
photo_file: null,
|
||||||
|
photo_preview_url: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _badge(deployed) {
|
||||||
|
return deployed
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">deployed</span>'
|
||||||
|
: '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">benched</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapGoToStep(n) {
|
||||||
|
_swap.step = n;
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const el = document.getElementById('swap-step-' + i);
|
||||||
|
if (el) el.classList.toggle('hidden', i !== n);
|
||||||
|
const pill = document.getElementById('swap-pill-' + i);
|
||||||
|
if (!pill) continue;
|
||||||
|
const dot = pill.querySelector('span');
|
||||||
|
if (i === n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-seismo-orange', 'font-medium');
|
||||||
|
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
dot.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
} else if (i < n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-green-600', 'dark:text-green-400');
|
||||||
|
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'bg-seismo-orange', 'text-white');
|
||||||
|
dot.classList.add('bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
|
||||||
|
} else {
|
||||||
|
pill.classList.add('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.remove('text-seismo-orange', 'font-medium', 'text-green-600', 'dark:text-green-400');
|
||||||
|
dot.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
dot.classList.remove('bg-seismo-orange', 'text-white', 'bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('swap-step-done').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: project picker ──────────────────────────────────────────
|
||||||
|
async function _swapLoadProjects() {
|
||||||
|
const list = document.getElementById('swap-project-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/projects/search-json?limit=50');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
_swap.all_projects = await r.json();
|
||||||
|
_swapRenderProjects();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderProjects() {
|
||||||
|
const q = document.getElementById('swap-project-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-project-list');
|
||||||
|
let items = _swap.all_projects;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(p => {
|
||||||
|
const hay = [(p.project_number||''), (p.client_name||''), (p.name||''), (p.display||'')]
|
||||||
|
.join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching projects.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(p => {
|
||||||
|
const num = p.project_number ? `<span class="font-mono text-xs text-gray-500 dark:text-gray-400">${_esc(p.project_number)}</span>` : '';
|
||||||
|
const client = p.client_name ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(p.client_name)}</div>` : '';
|
||||||
|
return `<button onclick='swapPickProject(${JSON.stringify(p.id)})'
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(p.name)}</span>
|
||||||
|
${num}
|
||||||
|
</div>
|
||||||
|
${client}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickProject(projectId) {
|
||||||
|
const p = _swap.all_projects.find(x => x.id === projectId);
|
||||||
|
if (!p) return;
|
||||||
|
_swap.project = p;
|
||||||
|
document.getElementById('swap-project-label').textContent = p.name;
|
||||||
|
_swapLoadLocations();
|
||||||
|
swapGoToStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: location picker ─────────────────────────────────────────
|
||||||
|
async function _swapLoadLocations() {
|
||||||
|
const list = document.getElementById('swap-location-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/locations-with-assignments?location_type=vibration`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
_swapRenderLocations(data);
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderLocations(locations) {
|
||||||
|
const list = document.getElementById('swap-location-list');
|
||||||
|
if (!locations || locations.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No vibration locations in this project.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = locations.map(loc => {
|
||||||
|
const unit = loc.unit;
|
||||||
|
const modem = loc.modem;
|
||||||
|
const unitLine = unit
|
||||||
|
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
|
||||||
|
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
|
||||||
|
const modemLine = modem
|
||||||
|
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono mt-0.5 flex items-center gap-2">
|
||||||
|
<span>+ modem ${_esc(modem.id)}</span>
|
||||||
|
${_badge(modem.deployed)}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
// Pass index for cleaner attribute escaping
|
||||||
|
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(loc.name)}</span>
|
||||||
|
</div>
|
||||||
|
${unitLine}
|
||||||
|
${modemLine}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
_swap._locations_cache = locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapPickLocationByIdx(idxStr) {
|
||||||
|
const idx = parseInt(idxStr, 10);
|
||||||
|
const loc = _swap._locations_cache[idx];
|
||||||
|
if (!loc) return;
|
||||||
|
_swap.location = loc;
|
||||||
|
document.getElementById('swap-location-label').textContent = loc.name;
|
||||||
|
document.getElementById('swap-old-unit-label').textContent = loc.unit ? loc.unit.id : '(empty)';
|
||||||
|
_swapLoadUnits();
|
||||||
|
swapGoToStep(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: incoming unit picker ────────────────────────────────────
|
||||||
|
async function _swapLoadUnits() {
|
||||||
|
const list = document.getElementById('swap-unit-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration&include_benched=true`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
_swap.all_units = await r.json();
|
||||||
|
_swapRenderUnits();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderUnits() {
|
||||||
|
const q = document.getElementById('swap-unit-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-unit-list');
|
||||||
|
let items = _swap.all_units;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(u => {
|
||||||
|
const hay = [(u.id||''), (u.model||''), (u.location||'')].join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No available seismographs.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(u => {
|
||||||
|
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
|
||||||
|
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
|
||||||
|
const badge = _badge(u.deployed);
|
||||||
|
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
|
${model}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${loc}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickUnit(unitId) {
|
||||||
|
const u = _swap.all_units.find(x => x.id === unitId);
|
||||||
|
if (!u) return;
|
||||||
|
_swap.new_unit = u;
|
||||||
|
document.getElementById('swap-new-unit-label').textContent = u.id;
|
||||||
|
_swapInitModemStep();
|
||||||
|
swapGoToStep(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: modem decision ──────────────────────────────────────────
|
||||||
|
function _swapInitModemStep() {
|
||||||
|
_swap.modem_action = null;
|
||||||
|
_swap.new_modem = null;
|
||||||
|
document.getElementById('swap-modem-picker-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('swap-modem-next').disabled = true;
|
||||||
|
|
||||||
|
const current = _swap.location && _swap.location.modem;
|
||||||
|
const choices = document.getElementById('swap-modem-choice-list');
|
||||||
|
const question = document.getElementById('swap-modem-question');
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
question.textContent = 'Modem at this location';
|
||||||
|
choices.innerHTML = `
|
||||||
|
<button data-action="keep" onclick="swapPickModemAction('keep')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
|
||||||
|
${_badge(current.deployed)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="swap" onclick="swapPickModemAction('swap')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Swap modem too</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Pick a different unassigned modem.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="remove" onclick="swapPickModemAction('remove')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Incoming unit goes solo (no cellular).</div>
|
||||||
|
</button>`;
|
||||||
|
} else {
|
||||||
|
question.textContent = 'No modem at this location currently.';
|
||||||
|
choices.innerHTML = `
|
||||||
|
<button data-action="none" onclick="swapPickModemAction('none')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Standalone / manual download.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="add" onclick="swapPickModemAction('add')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Add a modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Pair an unassigned modem with the incoming unit.</div>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickModemAction(action) {
|
||||||
|
_swap.modem_action = action;
|
||||||
|
_swap.new_modem = null;
|
||||||
|
// Highlight the picked choice; dim the others.
|
||||||
|
document.querySelectorAll('.swap-modem-choice').forEach(btn => {
|
||||||
|
if (btn.dataset.action === action) {
|
||||||
|
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickerWrap = document.getElementById('swap-modem-picker-wrap');
|
||||||
|
const nextBtn = document.getElementById('swap-modem-next');
|
||||||
|
|
||||||
|
if (action === 'swap' || action === 'add') {
|
||||||
|
pickerWrap.classList.remove('hidden');
|
||||||
|
nextBtn.disabled = true;
|
||||||
|
_swapLoadModems();
|
||||||
|
} else {
|
||||||
|
pickerWrap.classList.add('hidden');
|
||||||
|
nextBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _swapLoadModems() {
|
||||||
|
const list = document.getElementById('swap-modem-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems?include_benched=true`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
|
||||||
|
let modems = await r.json();
|
||||||
|
const currentModemId = _swap.location && _swap.location.modem ? _swap.location.modem.id : null;
|
||||||
|
if (currentModemId) modems = modems.filter(m => m.id !== currentModemId);
|
||||||
|
_swap.all_modems = modems;
|
||||||
|
_swapRenderModems();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderModems() {
|
||||||
|
const q = document.getElementById('swap-modem-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-modem-list');
|
||||||
|
let items = _swap.all_modems;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(m => {
|
||||||
|
const hay = [(m.id||''), (m.hardware_model||''), (m.ip_address||'')].join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No modems available.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(m => {
|
||||||
|
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
|
||||||
|
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
|
||||||
|
const badge = _badge(m.deployed);
|
||||||
|
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
|
||||||
|
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
|
||||||
|
data-modem-id="${_esc(m.id)}">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
|
${hw}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${ip}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickModem(modemId) {
|
||||||
|
const m = _swap.all_modems.find(x => x.id === modemId);
|
||||||
|
if (!m) return;
|
||||||
|
_swap.new_modem = m;
|
||||||
|
document.querySelectorAll('.swap-modem-pick').forEach(btn => {
|
||||||
|
if (btn.dataset.modemId === modemId) {
|
||||||
|
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('swap-modem-next').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapAfterModem() {
|
||||||
|
// Populate review screen
|
||||||
|
document.getElementById('swap-review-project').textContent = _swap.project.name;
|
||||||
|
document.getElementById('swap-review-location').textContent = _swap.location.name;
|
||||||
|
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
|
||||||
|
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
|
||||||
|
|
||||||
|
const oldModemEl = document.getElementById('swap-review-old-modem');
|
||||||
|
if (_swap.location.modem) {
|
||||||
|
oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`;
|
||||||
|
} else {
|
||||||
|
oldModemEl.textContent = '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newModemEl = document.getElementById('swap-review-new-modem');
|
||||||
|
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} <span class="text-xs text-gray-500">(kept)</span> ${_badge(_swap.location.modem.deployed)}`;
|
||||||
|
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`;
|
||||||
|
} else {
|
||||||
|
newModemEl.textContent = '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
swapGoToStep(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: confirm ─────────────────────────────────────────────────
|
||||||
|
async function swapConfirm() {
|
||||||
|
const btn = document.getElementById('swap-confirm-btn');
|
||||||
|
const err = document.getElementById('swap-error');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
|
||||||
|
// Determine modem_id to send:
|
||||||
|
// 'keep' → current modem id (re-pair to new unit)
|
||||||
|
// 'swap' → newly-picked modem id
|
||||||
|
// 'add' → newly-picked modem id
|
||||||
|
// 'remove' → omit (endpoint clears new unit's pairing)
|
||||||
|
// 'none' → omit
|
||||||
|
let modemIdToSend = null;
|
||||||
|
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
||||||
|
modemIdToSend = _swap.location.modem.id;
|
||||||
|
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
||||||
|
modemIdToSend = _swap.new_modem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Swapping…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('unit_id', _swap.new_unit.id);
|
||||||
|
if (modemIdToSend) fd.append('modem_id', modemIdToSend);
|
||||||
|
const notes = document.getElementById('swap-notes').value.trim();
|
||||||
|
if (notes) fd.append('notes', notes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/projects/${encodeURIComponent(_swap.project.id)}/locations/${encodeURIComponent(_swap.location.id)}/swap`;
|
||||||
|
const r = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
_swap.swap_result = await r.json();
|
||||||
|
_swapShowDone();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Confirm swap';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapShowDone() {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
document.getElementById('swap-step-' + i).classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('swap-step-done').classList.remove('hidden');
|
||||||
|
const summary = `${_swap.new_unit.id} is now at ${_swap.location.name}` +
|
||||||
|
(_swap.location.unit ? ` (replacing ${_swap.location.unit.id}).` : '.');
|
||||||
|
document.getElementById('swap-done-summary').textContent = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo upload (optional) ─────────────────────────────────────────
|
||||||
|
function swapOnPhotoPicked(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
_swap.photo_file = file;
|
||||||
|
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
|
||||||
|
_swap.photo_preview_url = URL.createObjectURL(file);
|
||||||
|
document.getElementById('swap-photo-preview').src = _swap.photo_preview_url;
|
||||||
|
document.getElementById('swap-photo-preview-wrap').classList.remove('hidden');
|
||||||
|
document.getElementById('swap-photo-upload-btn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapUploadPhoto() {
|
||||||
|
if (!_swap.photo_file) return;
|
||||||
|
const btn = document.getElementById('swap-photo-upload-btn');
|
||||||
|
const err = document.getElementById('swap-photo-error');
|
||||||
|
const ok = document.getElementById('swap-photo-status');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
ok.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', _swap.photo_file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/unit/${encodeURIComponent(_swap.new_unit.id)}/upload-photo?auto_populate_coords=true`;
|
||||||
|
const r = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
const coords = data && data.metadata && data.metadata.coordinates;
|
||||||
|
ok.textContent = coords ? `Uploaded. GPS: ${coords}` : 'Uploaded (no GPS in EXIF).';
|
||||||
|
ok.classList.remove('hidden');
|
||||||
|
btn.textContent = 'Uploaded';
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Upload photo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapReset() {
|
||||||
|
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
|
||||||
|
Object.assign(_swap, {
|
||||||
|
project: null, location: null, new_unit: null,
|
||||||
|
modem_action: null, new_modem: null,
|
||||||
|
all_projects: [], all_units: [], all_modems: [],
|
||||||
|
swap_result: null, photo_file: null, photo_preview_url: null,
|
||||||
|
});
|
||||||
|
document.getElementById('swap-project-search').value = '';
|
||||||
|
document.getElementById('swap-unit-search').value = '';
|
||||||
|
document.getElementById('swap-modem-search').value = '';
|
||||||
|
document.getElementById('swap-notes').value = '';
|
||||||
|
document.getElementById('swap-photo-input').value = '';
|
||||||
|
document.getElementById('swap-photo-preview-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('swap-photo-error').classList.add('hidden');
|
||||||
|
document.getElementById('swap-photo-status').classList.add('hidden');
|
||||||
|
document.getElementById('swap-error').classList.add('hidden');
|
||||||
|
const confirmBtn = document.getElementById('swap-confirm-btn');
|
||||||
|
confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm swap';
|
||||||
|
const photoBtn = document.getElementById('swap-photo-upload-btn');
|
||||||
|
photoBtn.disabled = true; photoBtn.textContent = 'Upload photo';
|
||||||
|
swapGoToStep(1);
|
||||||
|
_swapLoadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up live filtering inputs.
|
||||||
|
document.getElementById('swap-project-search').addEventListener('input', _swapRenderProjects);
|
||||||
|
document.getElementById('swap-unit-search').addEventListener('input', _swapRenderUnits);
|
||||||
|
document.getElementById('swap-modem-search').addEventListener('input', _swapRenderModems);
|
||||||
|
|
||||||
|
// Kick off.
|
||||||
|
_swapLoadProjects();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SFM Event DB Manager - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6 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 Event DB Manager</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Browse, flag, and delete triggered events from SFM's events table. Destructive actions also clean up on-disk waveform / sidecar / pickle / hdf5 files.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadEvents()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning banner -->
|
||||||
|
<div class="rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-2xl">⚠️</span>
|
||||||
|
<div class="text-sm text-red-900 dark:text-red-200">
|
||||||
|
<strong class="font-semibold">Destructive operations.</strong>
|
||||||
|
Delete actions remove rows from SFM's events table AND delete associated waveform files on disk. Both are permanent — there is no undo. Use filters carefully, dry-run first, and verify the match count before confirming.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-5 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Filters</h2>
|
||||||
|
<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">Serial</label>
|
||||||
|
<input type="text" id="f-serial" placeholder="e.g. BE9558"
|
||||||
|
onkeydown="if (event.key === 'Enter') loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm font-mono w-40 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||||
|
<input type="datetime-local" id="f-from"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||||
|
<input type="datetime-local" id="f-to"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
|
||||||
|
<select id="f-ft" class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="false">Real Only</option>
|
||||||
|
<option value="true">FT Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
|
||||||
|
<select id="f-limit" class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="500" selected>500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
<option value="5000">5000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadEvents()"
|
||||||
|
class="px-4 py-1.5 text-sm font-medium rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button onclick="clearFilters()"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk actions -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span id="bulk-selected" class="text-sm text-gray-600 dark:text-gray-400">0 selected</span>
|
||||||
|
|
||||||
|
<button id="bulk-delete-selected" onclick="deleteSelected()" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
🗑 Delete selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="deleteByFilter()"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-red-400 dark:border-red-600 bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 hover:bg-red-100 dark:hover:bg-red-900/50 font-medium">
|
||||||
|
🗑 Delete ALL matching current filter…
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="border-l border-gray-300 dark:border-gray-600 h-6"></div>
|
||||||
|
|
||||||
|
<button id="bulk-flag-ft" onclick="flagSelected(true)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
🚩 Flag as FT
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="bulk-clear-ft" onclick="flagSelected(false)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
✓ Clear FT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700" id="results-summary">
|
||||||
|
Apply filters above to load events.
|
||||||
|
</div>
|
||||||
|
<div id="results-table" class="overflow-x-auto">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No query run yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SFM_PROXY = '/api/sfm';
|
||||||
|
const _selected = new Set();
|
||||||
|
let _events = [];
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
function _fmtPpv(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); }
|
||||||
|
|
||||||
|
function _refreshBulkButtons() {
|
||||||
|
const n = _selected.size;
|
||||||
|
document.getElementById('bulk-selected').textContent = `${n} selected`;
|
||||||
|
document.getElementById('bulk-delete-selected').disabled = (n === 0);
|
||||||
|
document.getElementById('bulk-flag-ft').disabled = (n === 0);
|
||||||
|
document.getElementById('bulk-clear-ft').disabled = (n === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _currentFilter() {
|
||||||
|
const f = {};
|
||||||
|
const serial = document.getElementById('f-serial').value.trim();
|
||||||
|
const from = document.getElementById('f-from').value;
|
||||||
|
const to = document.getElementById('f-to').value;
|
||||||
|
const ft = document.getElementById('f-ft').value;
|
||||||
|
if (serial) f.serial = serial;
|
||||||
|
if (from) f.from_dt = from;
|
||||||
|
if (to) f.to_dt = to;
|
||||||
|
if (ft === 'true') f.false_trigger = true;
|
||||||
|
if (ft === 'false') f.false_trigger = false;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
document.getElementById('f-serial').value = '';
|
||||||
|
document.getElementById('f-from').value = '';
|
||||||
|
document.getElementById('f-to').value = '';
|
||||||
|
document.getElementById('f-ft').value = '';
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const f = _currentFilter();
|
||||||
|
const limit = document.getElementById('f-limit').value || '500';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (f.serial) params.set('serial', f.serial);
|
||||||
|
if (f.from_dt) params.set('from_dt', f.from_dt);
|
||||||
|
if (f.to_dt) params.set('to_dt', f.to_dt);
|
||||||
|
if (f.false_trigger !== undefined) params.set('false_trigger', String(f.false_trigger));
|
||||||
|
params.set('limit', limit);
|
||||||
|
|
||||||
|
const container = document.getElementById('results-table');
|
||||||
|
const summary = document.getElementById('results-summary');
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events?${params.toString()}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: HTTP ${resp.status}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
_events = data.events || [];
|
||||||
|
summary.textContent = `Showing ${_events.length} event${_events.length === 1 ? '' : 's'} (limit ${limit})`;
|
||||||
|
renderTable();
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: ${_esc(err.message || err)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const container = document.getElementById('results-table');
|
||||||
|
if (_events.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">No events match the current filter.</div>';
|
||||||
|
_refreshBulkButtons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = _events.map(ev => {
|
||||||
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||||
|
const ft = ev.false_trigger
|
||||||
|
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
||||||
|
: '';
|
||||||
|
const checked = _selected.has(ev.id) ? 'checked' : '';
|
||||||
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||||
|
<td class="px-3 py-2"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.vert_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.long_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right font-semibold">${_fmtPpv(ev.peak_vector_sum)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm">${ft}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-gray-500 dark:text-gray-400 truncate" style="max-width:240px;" title="${_esc(ev.id)}">${_esc(ev.id).slice(0, 8)}…</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<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-3 py-2"><input type="checkbox" id="check-all" onchange="toggleAllRows(this.checked)"></th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Serial</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Timestamp</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Tran</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Vert</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Long</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">PVS</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Flags</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowCheck(input) {
|
||||||
|
const id = input.getAttribute('data-event-id');
|
||||||
|
if (input.checked) _selected.add(id);
|
||||||
|
else {
|
||||||
|
_selected.delete(id);
|
||||||
|
const master = document.getElementById('check-all');
|
||||||
|
if (master) master.checked = false;
|
||||||
|
}
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllRows(checked) {
|
||||||
|
document.querySelectorAll('.row-check').forEach(cb => {
|
||||||
|
const id = cb.getAttribute('data-event-id');
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) _selected.add(id);
|
||||||
|
else _selected.delete(id);
|
||||||
|
});
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (_selected.size === 0) return;
|
||||||
|
const ids = Array.from(_selected);
|
||||||
|
if (!confirm(`PERMANENTLY delete ${ids.length} event${ids.length === 1 ? '' : 's'} from the SFM DB?\n\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids, confirm: true }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Delete failed: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteByFilter() {
|
||||||
|
const f = _currentFilter();
|
||||||
|
if (Object.keys(f).length === 0) {
|
||||||
|
if (!confirm('No filters set — this would attempt to delete EVERY event in the SFM DB.\n\nAre you absolutely sure? You probably want a serial filter at minimum.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dry-run first
|
||||||
|
let matched = 0;
|
||||||
|
let sample_serials = [];
|
||||||
|
try {
|
||||||
|
const dry = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(f),
|
||||||
|
});
|
||||||
|
const dryData = await dry.json();
|
||||||
|
if (!dry.ok) {
|
||||||
|
alert(`Dry-run failed: HTTP ${dry.status}\n${JSON.stringify(dryData)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matched = dryData.matched || 0;
|
||||||
|
sample_serials = dryData.sample_serials || [];
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Dry-run failed: ${err.message || err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matched === 0) {
|
||||||
|
alert('No events match the current filter — nothing to delete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLines = Object.entries(f).map(([k, v]) => ` ${k} = ${v}`).join('\n') || ' (none)';
|
||||||
|
const serialList = sample_serials.length ? ` serials: ${sample_serials.join(', ')}${sample_serials.length === 5 ? ' (and possibly more)' : ''}\n` : '';
|
||||||
|
if (!confirm(`PERMANENTLY delete ${matched} event${matched === 1 ? '' : 's'}?\n\nFilter:\n${filterLines}\n${serialList}\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...f, confirm: true }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.status === 'too_many') {
|
||||||
|
alert(`Refused: ${data.hint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Delete failed: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flagSelected(value) {
|
||||||
|
if (_selected.size === 0) return;
|
||||||
|
const ids = Array.from(_selected);
|
||||||
|
const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on';
|
||||||
|
if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) return;
|
||||||
|
let ok = 0, failed = 0, cursor = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < ids.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
const id = ids[i];
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${SFM_PROXY}/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`,
|
||||||
|
{ method: 'PATCH' });
|
||||||
|
if (r.ok) ok++; else failed++;
|
||||||
|
} catch (_) { failed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: 8 }, worker));
|
||||||
|
if (failed) alert(`${ok} updated, ${failed} failed.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial empty state — let the user choose to load.
|
||||||
|
</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 %}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Watcher Manager — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Watcher Manager</h1>
|
||||||
|
<span class="px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded-full">Admin</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mt-1 text-sm">
|
||||||
|
Monitor and manage field watcher agents. Data updates on each heartbeat received.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent cards -->
|
||||||
|
<div id="agent-list" class="space-y-4">
|
||||||
|
|
||||||
|
{% if not agents %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow p-8 text-center">
|
||||||
|
<svg class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">No watcher agents have reported in yet.</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Once a watcher sends its first heartbeat it will appear here.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for agent in agents %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden" id="agent-{{ agent.id | replace(' ', '-') }}">
|
||||||
|
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-slate-700">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Status dot -->
|
||||||
|
{% if agent.status == 'ok' %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-green-500 flex-shrink-0"></span>
|
||||||
|
{% elif agent.status == 'pending' %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-yellow-400 flex-shrink-0"></span>
|
||||||
|
{% elif agent.status in ('missing', 'error') %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-red-500 flex-shrink-0"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-gray-400 flex-shrink-0"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ agent.id }}</h2>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
<span>{{ agent.source_type }}</span>
|
||||||
|
{% if agent.version %}
|
||||||
|
<span class="bg-gray-100 dark:bg-slate-700 px-1.5 py-0.5 rounded font-mono">v{{ agent.version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if agent.ip_address %}
|
||||||
|
<span>{{ agent.ip_address }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Status badge -->
|
||||||
|
{% if agent.status == 'ok' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">OK</span>
|
||||||
|
{% elif agent.status == 'pending' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Pending</span>
|
||||||
|
{% elif agent.status == 'missing' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Missing</span>
|
||||||
|
{% elif agent.status == 'error' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Error</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400">Unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Trigger Update button -->
|
||||||
|
<button
|
||||||
|
onclick="triggerUpdate('{{ agent.id }}')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"
|
||||||
|
id="btn-update-{{ agent.id | replace(' ', '-') }}"
|
||||||
|
>
|
||||||
|
Trigger Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta row -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-slate-800 border-b border-gray-100 dark:border-slate-700 flex flex-wrap gap-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Last seen</span>
|
||||||
|
<span class="last-seen-value ml-2 font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{% if agent.last_seen %}
|
||||||
|
{{ agent.last_seen }}
|
||||||
|
{% if agent.age_minutes is not none %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 font-normal">({{ agent.age_minutes }}m ago)</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="update-pending-indicator flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400 {% if not agent.update_pending %}hidden{% endif %}">
|
||||||
|
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-semibold">Update pending — will apply on next heartbeat</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log tail -->
|
||||||
|
{% if agent.log_tail %}
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Log Tail</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="expandLog('{{ agent.id | replace(' ', '-') }}')" id="expand-{{ agent.id | replace(' ', '-') }}" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
Expand
|
||||||
|
</button>
|
||||||
|
<button onclick="toggleLog('{{ agent.id | replace(' ', '-') }}')" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
Toggle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre id="log-{{ agent.id | replace(' ', '-') }}" class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-3 overflow-x-auto max-h-96 overflow-y-auto leading-relaxed hidden">{{ agent.log_tail | log_tail_display }}</pre>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-6 py-4 text-xs text-gray-400 dark:text-gray-500 italic">No log data received yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-refresh every 30s -->
|
||||||
|
<div class="mt-6 text-xs text-gray-400 dark:text-gray-600 text-center">
|
||||||
|
Auto-refreshes every 30 seconds — or <a href="/admin/watchers" class="underline hover:text-gray-600 dark:hover:text-gray-400">refresh now</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function triggerUpdate(agentId) {
|
||||||
|
if (!confirm('Trigger update for ' + agentId + '?\n\nThe watcher will self-update on its next heartbeat cycle.')) return;
|
||||||
|
|
||||||
|
const safeId = agentId.replace(/ /g, '-');
|
||||||
|
const btn = document.getElementById('btn-update-' + safeId);
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Sending...';
|
||||||
|
|
||||||
|
fetch('/api/admin/watchers/' + encodeURIComponent(agentId) + '/trigger-update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.ok) {
|
||||||
|
btn.textContent = 'Update Queued';
|
||||||
|
btn.classList.remove('bg-seismo-orange', 'hover:bg-orange-600');
|
||||||
|
btn.classList.add('bg-green-600');
|
||||||
|
// Show the pending indicator immediately without a reload
|
||||||
|
const card = document.getElementById('agent-' + safeId);
|
||||||
|
if (card) {
|
||||||
|
const indicator = card.querySelector('.update-pending-indicator');
|
||||||
|
if (indicator) indicator.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.classList.add('bg-red-600');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.textContent = 'Failed';
|
||||||
|
btn.classList.add('bg-red-600');
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLog(agentId) {
|
||||||
|
const el = document.getElementById('log-' + agentId);
|
||||||
|
if (el) el.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandLog(agentId) {
|
||||||
|
const el = document.getElementById('log-' + agentId);
|
||||||
|
const btn = document.getElementById('expand-' + agentId);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
if (el.classList.contains('max-h-96')) {
|
||||||
|
el.classList.remove('max-h-96');
|
||||||
|
el.style.maxHeight = 'none';
|
||||||
|
if (btn) btn.textContent = 'Collapse';
|
||||||
|
} else {
|
||||||
|
el.classList.add('max-h-96');
|
||||||
|
el.style.maxHeight = '';
|
||||||
|
if (btn) btn.textContent = 'Expand';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status colors for dot and badge by status value
|
||||||
|
const STATUS_DOT = {
|
||||||
|
ok: 'bg-green-500',
|
||||||
|
pending: 'bg-yellow-400',
|
||||||
|
missing: 'bg-red-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
};
|
||||||
|
const STATUS_BADGE_CLASSES = {
|
||||||
|
ok: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
|
||||||
|
missing: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
error: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
};
|
||||||
|
const STATUS_BADGE_DEFAULT = 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400';
|
||||||
|
const DOT_COLORS = ['bg-green-500', 'bg-yellow-400', 'bg-red-500', 'bg-gray-400'];
|
||||||
|
const BADGE_COLORS = [
|
||||||
|
'bg-green-100', 'text-green-700', 'dark:bg-green-900', 'dark:text-green-300',
|
||||||
|
'bg-yellow-100', 'text-yellow-700', 'dark:bg-yellow-900', 'dark:text-yellow-300',
|
||||||
|
'bg-red-100', 'text-red-700', 'dark:bg-red-900', 'dark:text-red-300',
|
||||||
|
'bg-gray-100', 'text-gray-600', 'dark:bg-slate-700', 'dark:text-gray-400',
|
||||||
|
];
|
||||||
|
|
||||||
|
function patchAgent(card, agent) {
|
||||||
|
// Status dot
|
||||||
|
const dot = card.querySelector('.status-dot');
|
||||||
|
if (dot) {
|
||||||
|
dot.classList.remove(...DOT_COLORS);
|
||||||
|
dot.classList.add(STATUS_DOT[agent.status] || 'bg-gray-400');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
const badge = card.querySelector('.status-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.classList.remove(...BADGE_COLORS);
|
||||||
|
const label = agent.status ? agent.status.charAt(0).toUpperCase() + agent.status.slice(1) : 'Unknown';
|
||||||
|
badge.textContent = label === 'Ok' ? 'OK' : label;
|
||||||
|
const cls = STATUS_BADGE_CLASSES[agent.status] || STATUS_BADGE_DEFAULT;
|
||||||
|
badge.classList.add(...cls.split(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last seen / age
|
||||||
|
const lastSeen = card.querySelector('.last-seen-value');
|
||||||
|
if (lastSeen) {
|
||||||
|
if (agent.last_seen) {
|
||||||
|
const age = agent.age_minutes != null
|
||||||
|
? ` <span class="text-gray-400 dark:text-gray-500 font-normal">(${agent.age_minutes}m ago)</span>`
|
||||||
|
: '';
|
||||||
|
lastSeen.innerHTML = agent.last_seen + age;
|
||||||
|
} else {
|
||||||
|
lastSeen.textContent = 'Never';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pending indicator
|
||||||
|
const indicator = card.querySelector('.update-pending-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.classList.toggle('hidden', !agent.update_pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveRefresh() {
|
||||||
|
fetch('/api/admin/watchers')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(agents => {
|
||||||
|
agents.forEach(agent => {
|
||||||
|
const safeId = agent.id.replace(/ /g, '-');
|
||||||
|
const card = document.getElementById('agent-' + safeId);
|
||||||
|
if (card) patchAgent(card, agent);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {}); // silently ignore fetch errors
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(liveRefresh, 30000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+57
-41
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar (Responsive) -->
|
<!-- Sidebar (Responsive) -->
|
||||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<a href="/" class="block">
|
<a href="/" class="block">
|
||||||
@@ -109,41 +109,24 @@
|
|||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</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">
|
<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>
|
<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>
|
</svg>
|
||||||
Devices
|
Devices
|
||||||
</a>
|
</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="/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 %}">
|
<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">
|
<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>
|
<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>
|
||||||
@@ -151,11 +134,40 @@
|
|||||||
Projects
|
Projects
|
||||||
</a>
|
</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.startswith('/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 %}">
|
<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">
|
<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>
|
<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>
|
||||||
Fleet Calendar
|
Job Planner
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
@@ -193,14 +205,18 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="main-content flex-1 overflow-y-auto">
|
<main class="main-content flex-1 overflow-y-auto">
|
||||||
<div class="p-8">
|
<div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) -->
|
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
||||||
<nav class="bottom-nav">
|
Deploy is here because the whole point of the workflow is "I'm
|
||||||
|
on site, capture this install in 90s before I leave." Devices,
|
||||||
|
Settings, Projects, Job Planner reachable via the hamburger
|
||||||
|
Menu (slot 1) which opens the full sidebar drawer. -->
|
||||||
|
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<div class="grid grid-cols-4 h-16">
|
<div class="grid grid-cols-4 h-16">
|
||||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -214,18 +230,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
<button class="bottom-nav-btn" data-href="/deploy" onclick="window.location.href='/deploy'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg 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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Devices</span>
|
<span>Deploy</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg 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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>Settings</span>
|
<span>Events</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+277
-142
@@ -10,17 +10,75 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-8 flex justify-between items-center">
|
<div class="mb-8 flex justify-between items-center gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="flex items-center gap-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
<a href="/deploy"
|
||||||
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
class="hidden md:inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-sm shadow"
|
||||||
|
title="Capture a field install — pick unit, snap photo, leave">
|
||||||
|
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
Field Deploy
|
||||||
|
</a>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
||||||
|
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending-deployments banner — auto-shows when there are field captures
|
||||||
|
awaiting classification. Hides itself when count is 0. Polled
|
||||||
|
alongside the rest of the dashboard's 10-second refresh. -->
|
||||||
|
<a id="pending-deploy-banner" href="/tools/pending-deployments"
|
||||||
|
class="hidden mb-6 flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-lg bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||||
|
<span id="pending-deploy-count">0</span> field deployment<span id="pending-deploy-plural">s</span> awaiting classification
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-amber-700 dark:text-amber-300">Click to pick project / location for these captures</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0" 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"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function _refreshPendingDeployBanner() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/deployments/pending?status=awaiting');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
const banner = document.getElementById('pending-deploy-banner');
|
||||||
|
const countEl = document.getElementById('pending-deploy-count');
|
||||||
|
const pluralEl = document.getElementById('pending-deploy-plural');
|
||||||
|
if (data.count > 0) {
|
||||||
|
countEl.textContent = data.count;
|
||||||
|
pluralEl.textContent = data.count === 1 ? '' : 's';
|
||||||
|
banner.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
banner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* silent — banner just stays hidden */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_refreshPendingDeployBanner();
|
||||||
|
setInterval(_refreshPendingDeployBanner, 30000);
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Dashboard cards with auto-refresh -->
|
<!-- Dashboard cards with auto-refresh -->
|
||||||
<div hx-get="/api/status-snapshot"
|
<div hx-get="/api/status-snapshot"
|
||||||
hx-trigger="load, every 10s"
|
hx-trigger="load, every 10s"
|
||||||
@@ -29,7 +87,55 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<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="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')">
|
<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>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
@@ -57,6 +163,10 @@
|
|||||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
||||||
|
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
||||||
|
</div>
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||||
<div class="flex justify-between items-center mb-1">
|
<div class="flex justify-between items-center mb-1">
|
||||||
@@ -117,74 +227,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard Filters -->
|
<!-- Dashboard Filters -->
|
||||||
@@ -265,6 +307,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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="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')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
@@ -360,6 +432,17 @@
|
|||||||
transform: rotate(-90deg);
|
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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -509,7 +592,7 @@ function renderFilteredDashboard(data) {
|
|||||||
// Update the Recent Alerts section with filtering
|
// Update the Recent Alerts section with filtering
|
||||||
function updateAlertsFiltered(filteredActive) {
|
function updateAlertsFiltered(filteredActive) {
|
||||||
const alertsList = document.getElementById('alerts-list');
|
const alertsList = document.getElementById('alerts-list');
|
||||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
|
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing' && u.device_type !== 'modem');
|
||||||
|
|
||||||
if (!missingUnits.length) {
|
if (!missingUnits.length) {
|
||||||
// Check if this is because of filters or genuinely no alerts
|
// Check if this is because of filters or genuinely no alerts
|
||||||
@@ -650,7 +733,8 @@ function toggleCard(cardName) {
|
|||||||
// Restore card states from localStorage on page load
|
// Restore card states from localStorage on page load
|
||||||
function restoreCardStates() {
|
function restoreCardStates() {
|
||||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
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 => {
|
cardNames.forEach(cardName => {
|
||||||
const content = document.getElementById(`${cardName}-content`);
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
@@ -703,6 +787,7 @@ function updateDashboard(event) {
|
|||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
|
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
||||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
@@ -834,89 +919,139 @@ async function loadRecentPhotos() {
|
|||||||
loadRecentPhotos();
|
loadRecentPhotos();
|
||||||
setInterval(loadRecentPhotos, 30000);
|
setInterval(loadRecentPhotos, 30000);
|
||||||
|
|
||||||
// Load and display recent call-ins
|
// Load and display recent call-ins.
|
||||||
let showingAllCallins = false;
|
// Source: SFM events (forwarded by series3-watcher from Blastware ACH).
|
||||||
const DEFAULT_CALLINS_DISPLAY = 5;
|
// Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
|
||||||
|
// is being phased out but kept as a backup.
|
||||||
async function loadRecentCallins() {
|
async function loadRecentCallins() {
|
||||||
|
const callinsList = document.getElementById('recent-callins-list');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/recent-callins?hours=6');
|
const response = await fetch('/api/recent-event-callins?limit=10');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load recent call-ins');
|
throw new Error('Failed to load recent call-ins');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
if (!data.call_ins || data.call_ins.length === 0) {
|
||||||
// Determine how many to show
|
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
|
||||||
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
return;
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Error loading recent call-ins:', 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
|
// Load recent call-ins on page load and refresh every 30 seconds.
|
||||||
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
|
|
||||||
loadRecentCallins();
|
loadRecentCallins();
|
||||||
setInterval(loadRecentCallins, 30000);
|
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>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Field Deploy - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Field Deploy</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Capture an install while you're still on site. Project + location can be picked later at a desk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="flex items-center justify-between mb-6 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div id="step-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-xs">1</span>
|
||||||
|
Unit
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
||||||
|
<div id="step-pill-2" class="flex items-center gap-1">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">2</span>
|
||||||
|
Photo
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
||||||
|
<div id="step-pill-3" class="flex items-center gap-1">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">3</span>
|
||||||
|
Confirm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Pick unit -->
|
||||||
|
<div id="step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which seismograph?</span>
|
||||||
|
<input id="unit-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Type a serial like BE12599…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||||
|
oninput="onUnitSearch()">
|
||||||
|
</label>
|
||||||
|
<div id="unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Photo -->
|
||||||
|
<div id="step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
|
||||||
|
<p class="font-mono font-semibold text-seismo-orange" id="step2-unit-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="goToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange">Change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Install photo</span>
|
||||||
|
<input id="photo-input" type="file" accept="image/*"
|
||||||
|
onchange="onPhotoPicked(event)"
|
||||||
|
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Take a new photo or pick a previously taken one. EXIF GPS is auto-extracted either way.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="photo-preview-wrap" class="hidden">
|
||||||
|
<img id="photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="Install photo preview">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Note + confirm -->
|
||||||
|
<div id="step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
|
||||||
|
<p class="font-mono font-semibold text-seismo-orange" id="step3-unit-label">—</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="step3-photo-wrap" class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||||
|
<img id="step3-photo" class="w-full" alt="Install photo">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="step3-coords-status" class="text-xs"></div>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Site memo (optional)</span>
|
||||||
|
<textarea id="note-input" rows="3"
|
||||||
|
placeholder="e.g. Carnegie Museum, north entrance loading dock"
|
||||||
|
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Helpful when classifying later. Free text — anything that helps you remember.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="capture-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<button id="capture-submit"
|
||||||
|
onclick="submitCapture()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
|
||||||
|
Capture
|
||||||
|
</button>
|
||||||
|
<button onclick="goToStep(2)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Retake photo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success -->
|
||||||
|
<div id="step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4 text-center">
|
||||||
|
<div class="w-16 h-16 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Captured</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<span id="done-unit-label" class="font-mono font-semibold text-seismo-orange"></span>
|
||||||
|
is now in the pending hopper.
|
||||||
|
You can classify it from <a href="/tools/pending-deployments" class="text-seismo-orange hover:text-seismo-navy underline">Tools → Pending Deployments</a> later.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-2" id="done-coords-label"></p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button onclick="resetWizard()"
|
||||||
|
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||||
|
Deploy another
|
||||||
|
</button>
|
||||||
|
<a href="/tools/pending-deployments"
|
||||||
|
class="px-4 py-3 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 font-medium inline-flex items-center justify-center">
|
||||||
|
View pending
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let _deployState = {
|
||||||
|
unit_id: null,
|
||||||
|
photo_file: null,
|
||||||
|
photo_preview_url: null,
|
||||||
|
captured: null, // server response from /capture
|
||||||
|
};
|
||||||
|
|
||||||
|
let _unitSearchDebounce = null;
|
||||||
|
async function onUnitSearch() {
|
||||||
|
if (_unitSearchDebounce) clearTimeout(_unitSearchDebounce);
|
||||||
|
_unitSearchDebounce = setTimeout(_fetchUnitList, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchUnitList() {
|
||||||
|
const q = document.getElementById('unit-search').value.trim();
|
||||||
|
const list = document.getElementById('unit-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Searching…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/seismograph-picker?q=${encodeURIComponent(q)}&limit=20`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
_renderUnitList(data.units || []);
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Search failed: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderUnitList(units) {
|
||||||
|
const list = document.getElementById('unit-list');
|
||||||
|
if (units.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching units.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = units.map(u => {
|
||||||
|
const noteHtml = u.note ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.note)}</div>` : '';
|
||||||
|
const pendingBadge = u.has_pending
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 ml-2">already pending</span>'
|
||||||
|
: '';
|
||||||
|
return `<button onclick="onPickUnit('${_esc(u.id)}')"
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
||||||
|
${pendingBadge}
|
||||||
|
</div>
|
||||||
|
${noteHtml}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickUnit(unitId) {
|
||||||
|
_deployState.unit_id = unitId;
|
||||||
|
document.getElementById('step2-unit-label').textContent = unitId;
|
||||||
|
document.getElementById('step3-unit-label').textContent = unitId;
|
||||||
|
goToStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPhotoPicked(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
_deployState.photo_file = file;
|
||||||
|
// Show preview.
|
||||||
|
if (_deployState.photo_preview_url) URL.revokeObjectURL(_deployState.photo_preview_url);
|
||||||
|
_deployState.photo_preview_url = URL.createObjectURL(file);
|
||||||
|
document.getElementById('photo-preview').src = _deployState.photo_preview_url;
|
||||||
|
document.getElementById('step3-photo').src = _deployState.photo_preview_url;
|
||||||
|
document.getElementById('photo-preview-wrap').classList.remove('hidden');
|
||||||
|
// Advance after a tiny delay so the user sees the preview.
|
||||||
|
setTimeout(() => goToStep(3), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep(n) {
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const el = document.getElementById('step-' + i);
|
||||||
|
if (el) el.classList.toggle('hidden', i !== n);
|
||||||
|
const pill = document.getElementById('step-pill-' + i);
|
||||||
|
if (pill) {
|
||||||
|
if (i === n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-seismo-orange', 'font-medium');
|
||||||
|
pill.querySelector('span').classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
pill.querySelector('span').classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
} else {
|
||||||
|
pill.classList.add('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.remove('text-seismo-orange', 'font-medium');
|
||||||
|
pill.querySelector('span').classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
pill.querySelector('span').classList.remove('bg-seismo-orange', 'text-white');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('step-done').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCapture() {
|
||||||
|
const btn = document.getElementById('capture-submit');
|
||||||
|
const err = document.getElementById('capture-error');
|
||||||
|
const note = document.getElementById('note-input').value.trim();
|
||||||
|
err.classList.add('hidden');
|
||||||
|
|
||||||
|
if (!_deployState.unit_id || !_deployState.photo_file) {
|
||||||
|
err.textContent = 'Need a unit and a photo first.';
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Capturing…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('unit_id', _deployState.unit_id);
|
||||||
|
fd.append('operator_note', note);
|
||||||
|
fd.append('photo', _deployState.photo_file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/deployments/capture', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
_deployState.captured = data;
|
||||||
|
_showDone(data);
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Capture';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showDone(data) {
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
document.getElementById('step-' + i).classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('step-done').classList.remove('hidden');
|
||||||
|
document.getElementById('done-unit-label').textContent = data.pending_deployment.unit_id;
|
||||||
|
const coords = data.extracted_coords;
|
||||||
|
const status = document.getElementById('done-coords-label');
|
||||||
|
if (coords) {
|
||||||
|
status.textContent = `GPS: ${coords}`;
|
||||||
|
} else {
|
||||||
|
status.textContent = 'No GPS in photo EXIF — you can add coordinates when classifying.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWizard() {
|
||||||
|
_deployState = { unit_id: null, photo_file: null, photo_preview_url: null, captured: null };
|
||||||
|
document.getElementById('unit-search').value = '';
|
||||||
|
document.getElementById('note-input').value = '';
|
||||||
|
document.getElementById('photo-input').value = '';
|
||||||
|
document.getElementById('photo-preview-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('capture-error').classList.add('hidden');
|
||||||
|
const btn = document.getElementById('capture-submit');
|
||||||
|
btn.disabled = false; btn.textContent = 'Capture';
|
||||||
|
goToStep(1);
|
||||||
|
_fetchUnitList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off initial unit list on page load.
|
||||||
|
_fetchUnitList();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+1707
-52
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}Field Modems - Terra-View{% endblock %}
|
{% block title %}Field Modems - Terra-View{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "partials/fleet_tab_strip.html" %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Age -->
|
<!-- 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 %}">
|
<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 }}
|
{{ unit.age }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-id="{{ unit.id }}"
|
data-id="{{ unit.id }}"
|
||||||
data-type="{{ unit.device_type }}"
|
data-type="{{ unit.device_type }}"
|
||||||
@@ -60,7 +60,11 @@
|
|||||||
data-note="{{ unit.note if unit.note else '' }}">
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.out_for_calibration %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
|
{% elif not unit.deployed %}
|
||||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
@@ -72,6 +76,10 @@
|
|||||||
|
|
||||||
{% if unit.deployed %}
|
{% if unit.deployed %}
|
||||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -203,14 +211,18 @@
|
|||||||
<div class="unit-card device-card"
|
<div class="unit-card device-card"
|
||||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-unit-id="{{ unit.id }}"
|
data-unit-id="{{ unit.id }}"
|
||||||
data-age="{{ unit.age }}">
|
data-age="{{ unit.age }}">
|
||||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.out_for_calibration %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
|
{% elif not unit.deployed %}
|
||||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
@@ -224,12 +236,14 @@
|
|||||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
|
||||||
|
{% elif unit.allocated %}bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300
|
||||||
|
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
{% if unit.out_for_calibration %}Out for Cal{% elif unit.allocated %}Allocated{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,6 +293,10 @@
|
|||||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
⚡ Deployed
|
⚡ Deployed
|
||||||
</span>
|
</span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
🔧 Out for Calibration
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
📦 Benched
|
📦 Benched
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,103 +1,203 @@
|
|||||||
<!-- Reservations List -->
|
<!-- Reservations List -->
|
||||||
{% if reservations %}
|
{% if reservations %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
{% for item in reservations %}
|
{% for item in reservations %}
|
||||||
{% set res = item.reservation %}
|
{% set res = item.reservation %}
|
||||||
<div class="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
{% set card_id = "res-card-" ~ res.id %}
|
||||||
|
{% set detail_id = "res-detail-" ~ res.id %}
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
style="border-left: 4px solid {{ res.color }};">
|
style="border-left: 4px solid {{ res.color }};">
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Header row (always visible, clickable) -->
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
<div class="res-card-header flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors select-none"
|
||||||
{% if item.has_conflicts %}
|
data-res-id="{{ res.id }}"
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full"
|
onclick="toggleResCard('{{ res.id }}')">
|
||||||
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job">
|
|
||||||
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
|
<div class="flex-1 min-w-0">
|
||||||
</span>
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
||||||
|
{% if res.device_type == 'slm' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">SLM</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded">Seismograph</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.has_conflicts %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded"
|
||||||
|
title="{{ item.conflict_count }} unit(s) will need a calibration swap during this job">
|
||||||
|
{{ item.conflict_count }} cal swap{{ 's' if item.conflict_count != 1 else '' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ res.start_date.strftime('%b %d, %Y') }} –
|
||||||
|
{% if res.end_date %}
|
||||||
|
{{ res.end_date.strftime('%b %d, %Y') }}
|
||||||
|
{% elif res.end_date_tbd %}
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
||||||
|
{% if res.estimated_end_date %}
|
||||||
|
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Counts -->
|
||||||
|
<div class="flex flex-col items-end gap-1 mx-4 flex-shrink-0">
|
||||||
|
{% set full = item.assigned_count == item.location_count and item.location_count > 0 %}
|
||||||
|
{% set remaining = item.location_count - item.assigned_count %}
|
||||||
|
<!-- Number row -->
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">est. {% if res.estimated_units %}{{ res.estimated_units }}{% else %}—{% endif %}</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span class="text-base font-bold {% if full %}text-green-600 dark:text-green-400{% elif item.assigned_count == 0 %}text-gray-400 dark:text-gray-500{% else %}text-amber-500 dark:text-amber-400{% endif %}">
|
||||||
|
{{ item.assigned_count }}/{{ item.location_count }}
|
||||||
|
</span>
|
||||||
|
{% if remaining > 0 %}
|
||||||
|
<span class="text-xs text-amber-500 dark:text-amber-400 whitespace-nowrap">({{ remaining }} more)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- Progress squares -->
|
||||||
|
{% if item.location_count > 0 %}
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
{% for i in range(item.location_count) %}
|
||||||
|
<span class="w-3 h-3 rounded-sm {% if i < item.assigned_count %}{% if full %}bg-green-500{% else %}bg-amber-500{% endif %}{% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{{ res.start_date.strftime('%b %d, %Y') }} -
|
<!-- Action buttons -->
|
||||||
{% if res.end_date %}
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
{{ res.end_date.strftime('%b %d, %Y') }}
|
<!-- Assign units (always visible) -->
|
||||||
{% elif res.end_date_tbd %}
|
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
|
||||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
{% if res.estimated_end_date %}
|
title="Assign units">
|
||||||
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{% endif %}
|
<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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||||
{% else %}
|
</svg>
|
||||||
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</p>
|
<!-- "..." overflow menu -->
|
||||||
|
<div class="relative" onclick="event.stopPropagation()">
|
||||||
|
<button onclick="toggleResMenu('{{ res.id }}')"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="More options">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="res-menu-{{ res.id }}"
|
||||||
|
class="hidden absolute right-0 top-8 z-20 w-44 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||||
|
<button onclick="openPromoteModal('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
||||||
|
</svg>
|
||||||
|
Promote to Project
|
||||||
|
</button>
|
||||||
|
<button onclick="editReservation('{{ res.id }}'); toggleResMenu('{{ res.id }}')"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||||
|
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chevron -->
|
||||||
|
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" 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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable detail panel -->
|
||||||
|
<div id="{{ detail_id }}" class="hidden border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-slate-800/60 px-4 py-3">
|
||||||
|
|
||||||
{% if res.notes %}
|
{% if res.notes %}
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3 italic">{{ res.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Estimated</div>
|
||||||
|
<div class="font-medium {% if res.estimated_units %}text-gray-800 dark:text-gray-200{% else %}text-gray-400 dark:text-gray-500 italic{% endif %}">
|
||||||
|
{% if res.estimated_units %}{{ res.estimated_units }} unit{{ 's' if res.estimated_units != 1 else '' }}{% else %}not specified{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Locations</div>
|
||||||
|
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} of {{ item.location_count }} filled</div>
|
||||||
|
{% if item.assigned_count < item.location_count %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
|
||||||
|
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.location_count - item.assigned_count }} location{{ 's' if (item.location_count - item.assigned_count) != 1 else '' }} remaining</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.has_conflicts %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
|
||||||
|
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.conflict_count }} unit{{ 's' if item.conflict_count != 1 else '' }} will need swapping during job</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.assigned_units %}
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Monitoring Locations</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{% for u in item.assigned_units %}
|
||||||
|
<div class="rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
|
||||||
|
<div class="flex items-center gap-3 px-3 py-1.5">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
{% if u.location_name %}
|
||||||
|
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300 truncate">{{ u.location_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="openUnitDetailModal('{{ u.id }}')"
|
||||||
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left text-sm">{{ u.id }}</button>
|
||||||
|
</div>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
{% if u.power_type == 'ac' %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
|
||||||
|
{% elif u.power_type == 'solar' %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.deployed %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.last_calibrated %}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if u.notes %}
|
||||||
|
<p class="px-3 pb-1.5 text-xs text-gray-400 dark:text-gray-500 italic">{{ u.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No units assigned yet. Click the clipboard icon to plan.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right ml-4">
|
|
||||||
<p class="text-lg font-bold text-gray-900 dark:text-white">
|
|
||||||
{% if res.assignment_type == 'quantity' %}
|
|
||||||
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
|
|
||||||
{% else %}
|
|
||||||
{{ item.assigned_count }}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4 flex items-center gap-2">
|
|
||||||
<button onclick="editReservation('{{ res.id }}')"
|
|
||||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Edit reservation">
|
|
||||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
|
|
||||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Delete reservation">
|
|
||||||
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- toggleResCard, deleteReservation, editReservation, openUnitDetailModal defined in fleet_calendar.html -->
|
||||||
async function deleteReservation(id, name) {
|
|
||||||
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
alert('Error: ' + (data.detail || 'Failed to delete'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error deleting reservation');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editReservation(id) {
|
|
||||||
// For now, just show alert - can implement edit modal later
|
|
||||||
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" 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 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"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</p>
|
<p class="text-gray-500 dark:text-gray-400">No jobs yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Job" to start planning a deployment</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -63,16 +63,25 @@ Include this modal in pages that use the project picker.
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
Project Type <span class="text-red-500">*</span>
|
Modules
|
||||||
|
<span class="text-gray-400 font-normal">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="project_type_id"
|
<div class="grid grid-cols-2 gap-2">
|
||||||
id="qcp-project-type"
|
<label class="flex items-center gap-2 p-2.5 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-orange-400 has-[:checked]:border-orange-400 has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-orange-900/20 transition-colors">
|
||||||
required
|
<input type="checkbox" name="module_sound" value="1" class="accent-seismo-orange">
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
<div>
|
||||||
<option value="vibration_monitoring">Vibration Monitoring</option>
|
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">Sound</p>
|
||||||
<option value="sound_monitoring">Sound Monitoring</option>
|
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p>
|
||||||
<option value="combined">Combined (Vibration + Sound)</option>
|
</div>
|
||||||
</select>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 p-2.5 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-blue-400 has-[:checked]:border-blue-400 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20 transition-colors">
|
||||||
|
<input type="checkbox" name="module_vibration" value="1" class="accent-blue-500">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">Vibration</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Seismographs, modems</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -222,6 +231,20 @@ if (typeof openCreateProjectModal === 'undefined') {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
|
const projectId = result.project_id;
|
||||||
|
|
||||||
|
// Add selected modules
|
||||||
|
const moduleMap = { module_sound: 'sound_monitoring', module_vibration: 'vibration_monitoring' };
|
||||||
|
for (const [field, moduleType] of Object.entries(moduleMap)) {
|
||||||
|
if (formData.get(field)) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: moduleType }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build display text from form values
|
// Build display text from form values
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const projectNumber = formData.get('project_number');
|
const projectNumber = formData.get('project_number');
|
||||||
@@ -235,7 +258,7 @@ if (typeof openCreateProjectModal === 'undefined') {
|
|||||||
const displayText = parts.join(' - ');
|
const displayText = parts.join(' - ');
|
||||||
|
|
||||||
// Select the newly created project in the picker
|
// Select the newly created project in the picker
|
||||||
selectProject(result.project_id, displayText, pickerId);
|
selectProject(projectId, displayText, pickerId);
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
closeCreateProjectModal();
|
closeCreateProjectModal();
|
||||||
|
|||||||
@@ -1,69 +1,302 @@
|
|||||||
<!-- Project Locations List -->
|
<!-- Project Locations List — Active + Removed sections.
|
||||||
{% if locations %}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{% for item in locations %}
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
|
||||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
|
||||||
{{ item.location.name }}
|
|
||||||
</a>
|
|
||||||
{% if item.location.location_type %}
|
|
||||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
|
||||||
{{ item.location.location_type|capitalize }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</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 class="flex items-center gap-2">
|
Card layout:
|
||||||
{% if item.assignment %}
|
[drag handle] [location info] [unit pill] [⋮ menu]
|
||||||
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
(name link, description, address, sessions/events, coords)
|
||||||
Unassign
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<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">
|
|
||||||
Assign
|
|
||||||
</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)"
|
|
||||||
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 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">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
Active cards are draggable to reorder. Drop reorders the DOM
|
||||||
<span>Sessions: {{ item.session_count }}</span>
|
immediately and posts the new order to /api/projects/{p}/locations/reorder.
|
||||||
{% if item.assignment and item.assigned_unit %}
|
|
||||||
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
Removed cards are NOT reorderable (their order is historical) but
|
||||||
{% else %}
|
show a Restore button.
|
||||||
<span>No active assignment</span>
|
|
||||||
{% endif %}
|
The three-dot menu replaces the inline Unassign/Edit/Remove/Delete
|
||||||
</div>
|
pill buttons. Click ⋮ to open; click outside closes.
|
||||||
</div>
|
-->
|
||||||
{% endfor %}
|
|
||||||
</div>
|
{% if not active_locations and not removed_locations %}
|
||||||
{% else %}
|
|
||||||
<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">
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p>No locations added yet</p>
|
<p>No locations added yet</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ─── Active locations (draggable) ─── #}
|
||||||
|
{% if active_locations %}
|
||||||
|
<div class="space-y-3" id="active-locations-list" data-project-id="{{ project.id }}">
|
||||||
|
{% 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 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">
|
||||||
|
<!-- Drag handle + info -->
|
||||||
|
<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 }}"
|
||||||
|
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Right column: small assign/unassign pill + 3-dot menu -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if not item.assignment %}
|
||||||
|
<!-- Primary action: visible because the unassigned card
|
||||||
|
is most likely getting clicked on right after creation -->
|
||||||
|
<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">
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Three-dot kebab menu -->
|
||||||
|
<div class="relative inline-block location-menu-wrapper">
|
||||||
|
<button onclick="toggleLocationMenu(event, this)"
|
||||||
|
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"
|
||||||
|
title="More actions">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# ─── Removed locations (collapsed by default) ─── #}
|
||||||
|
{% if removed_locations %}
|
||||||
|
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 transition-transform group-open:rotate-90" 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"/>
|
||||||
|
</svg>
|
||||||
|
Removed locations
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
|
||||||
|
</span>
|
||||||
|
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="space-y-3 mt-3">
|
||||||
|
{% for item in removed_locations %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
|
||||||
|
Removed
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if item.location.removal_reason %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
|
||||||
|
{% endif %}
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button data-loc-id="{{ item.location.id }}"
|
||||||
|
data-loc-name="{{ item.location.name | e }}"
|
||||||
|
onclick="restoreLocation(this.dataset.locId, this.dataset.locName)"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
|
||||||
|
title="Restore to active monitoring">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>{{ "{:,}".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>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% 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>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!-- Reusable project-location map.
|
||||||
|
|
||||||
|
Renders a Leaflet map with one pin per active monitoring location for
|
||||||
|
the given project. Fetches data from /api/projects/{p}/locations-json
|
||||||
|
on load.
|
||||||
|
|
||||||
|
Required context variable:
|
||||||
|
project_id — UUID string
|
||||||
|
|
||||||
|
Optional context variable:
|
||||||
|
map_height — CSS height (default "320px")
|
||||||
|
location_type — filter the fetched list (default: all types)
|
||||||
|
|
||||||
|
Hover any .location-card on the page with a matching
|
||||||
|
data-location-id → highlights the pin. Click a pin → scrolls
|
||||||
|
the matching card into view + flashes an orange ring.
|
||||||
|
|
||||||
|
isolation:isolate on the container forces a new stacking context so
|
||||||
|
Leaflet's internal z-indexes (200-800) stay contained inside the
|
||||||
|
card instead of leaking out and rendering over modals (z-50).
|
||||||
|
-->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500" id="loc-map-status-{{ project_id }}"></span>
|
||||||
|
</div>
|
||||||
|
<div id="loc-map-{{ project_id }}"
|
||||||
|
class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
style="height: {{ map_height | default('320px') }}; background: rgba(0,0,0,0.05); isolation: isolate;">
|
||||||
|
</div>
|
||||||
|
<div id="loc-map-empty-{{ project_id }}" 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="loc-map-missing-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const projectId = {{ project_id | tojson }};
|
||||||
|
const locationType = {{ (location_type | default(none)) | tojson }};
|
||||||
|
const mapEl = document.getElementById('loc-map-' + projectId);
|
||||||
|
const emptyMsg = document.getElementById('loc-map-empty-' + projectId);
|
||||||
|
const missingMsg = document.getElementById('loc-map-missing-' + projectId);
|
||||||
|
const statusEl = document.getElementById('loc-map-status-' + projectId);
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
statusEl.textContent = 'loading…';
|
||||||
|
let locs;
|
||||||
|
try {
|
||||||
|
const qs = locationType ? `?location_type=${encodeURIComponent(locationType)}` : '';
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations-json${qs}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
locs = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'failed';
|
||||||
|
mapEl.innerHTML = `<div class="flex items-center justify-center h-full text-sm text-red-500">Map load failed: ${e.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = '';
|
||||||
|
|
||||||
|
const withCoords = [];
|
||||||
|
const withoutCoords = [];
|
||||||
|
for (const loc of locs) {
|
||||||
|
const xy = parseCoords(loc.coordinates);
|
||||||
|
if (xy) withCoords.push({ ...loc, latlon: xy });
|
||||||
|
else withoutCoords.push(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
mapEl.classList.add('hidden');
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 markersById = {};
|
||||||
|
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', () => _lmFlashCard(loc.id));
|
||||||
|
markersById[loc.id] = marker;
|
||||||
|
bounds.push(loc.latlon);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds.length === 1) map.setView(bounds[0], 14);
|
||||||
|
else map.fitBounds(bounds, { padding: [20, 20] });
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover any .location-card on the page → highlight matching pin.
|
||||||
|
let hoverPinId = null;
|
||||||
|
document.addEventListener('mouseover', (e) => {
|
||||||
|
const card = e.target.closest('.location-card');
|
||||||
|
if (!card) return;
|
||||||
|
const locId = card.dataset.locationId;
|
||||||
|
if (!markersById[locId] || locId === hoverPinId) return;
|
||||||
|
if (hoverPinId) _unhighlight(hoverPinId);
|
||||||
|
_highlight(locId);
|
||||||
|
hoverPinId = locId;
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseout', (e) => {
|
||||||
|
const card = e.target.closest('.location-card');
|
||||||
|
if (!card) return;
|
||||||
|
const related = e.relatedTarget && e.relatedTarget.closest('.location-card');
|
||||||
|
if (related === card) return;
|
||||||
|
if (hoverPinId) {
|
||||||
|
_unhighlight(hoverPinId);
|
||||||
|
hoverPinId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _highlight(locId) {
|
||||||
|
const m = markersById[locId]; if (!m) return;
|
||||||
|
m.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 });
|
||||||
|
m.openTooltip();
|
||||||
|
m.bringToFront();
|
||||||
|
}
|
||||||
|
function _unhighlight(locId) {
|
||||||
|
const m = markersById[locId]; if (!m) return;
|
||||||
|
m.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 });
|
||||||
|
m.closeTooltip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lmFlashCard(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if project.status == 'active' %}
|
{% if project.status == 'upcoming' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||||
|
{% elif project.status == 'active' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
{% elif project.status == 'on_hold' %}
|
{% elif project.status == 'on_hold' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
@@ -50,14 +52,14 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||||
NRLs
|
NRLs
|
||||||
{% else %}
|
{% else %}
|
||||||
Locations
|
Locations
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<button onclick="openLocationModal('{% if project_type and project_type.id == 'sound_monitoring' %}sound{% elif project_type and project_type.id == 'vibration_monitoring' %}vibration{% else %}{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
<button onclick="openLocationModal('{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}sound{% elif 'vibration_monitoring' in modules and 'sound_monitoring' not in modules %}vibration{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||||
Add NRL
|
Add NRL
|
||||||
{% else %}
|
{% else %}
|
||||||
Add Location
|
Add Location
|
||||||
@@ -65,7 +67,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="project-locations"
|
<div id="project-locations"
|
||||||
hx-get="/api/projects/{{ project.id }}/locations{% if project_type and project_type.id == 'sound_monitoring' %}?location_type=sound{% endif %}"
|
hx-get="/api/projects/{{ project.id }}/locations{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}?location_type=sound{% endif %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="animate-pulse space-y-3">
|
<div class="animate-pulse space-y-3">
|
||||||
@@ -76,22 +78,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
{# Location map — uses the reusable partial that fetches from
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
/api/projects/{p}/locations-json. Same render is reused on the
|
||||||
{% if upcoming_actions %}
|
deeper Vibration tab so both surfaces stay in sync. #}
|
||||||
<div class="space-y-3">
|
{% with project_id=project.id %}
|
||||||
{% for action in upcoming_actions %}
|
{% include 'partials/projects/location_map.html' %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
{% endwith %}
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
|
|
||||||
{% if action.description %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if upcoming_actions %}
|
||||||
|
<div class="mt-3 text-xs text-right text-gray-500 dark:text-gray-400">
|
||||||
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -3,15 +3,50 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
<div class="relative inline-block">
|
||||||
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
<select onchange="quickUpdateStatus(this.value)"
|
||||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
class="appearance-none cursor-pointer inline-flex items-center pl-3 pr-7 py-1 rounded-full text-sm font-medium border-0 focus:ring-2 focus:ring-offset-1 focus:ring-blue-500
|
||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
{% if project.status == 'upcoming' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
|
||||||
{{ project.status|title }}
|
{% elif project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||||
</span>
|
{% elif project.status == 'on_hold' %}bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
|
||||||
{% if project_type %}
|
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
{% endif %}
|
<option value="upcoming" {% if project.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
|
||||||
|
<option value="active" {% if project.status == 'active' %}selected{% endif %}>Active</option>
|
||||||
|
<option value="on_hold" {% if project.status == 'on_hold' %}selected{% endif %}>On Hold</option>
|
||||||
|
<option value="completed" {% if project.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||||
|
<option value="archived" {% if project.status == 'archived' %}selected{% endif %}>Archived</option>
|
||||||
|
</select>
|
||||||
|
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-current opacity-60">
|
||||||
|
<svg class="w-3 h-3" 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"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Module badges -->
|
||||||
|
<div id="module-badges" class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{% for m in modules %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium
|
||||||
|
{% if m == 'sound_monitoring' %}bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300
|
||||||
|
{% elif m == 'vibration_monitoring' %}bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300
|
||||||
|
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
|
{% if m == 'sound_monitoring' %}
|
||||||
|
<svg class="w-3 h-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.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
|
||||||
|
Sound Monitoring
|
||||||
|
{% elif m == 'vibration_monitoring' %}
|
||||||
|
<svg class="w-3 h-3" 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>
|
||||||
|
Vibration Monitoring
|
||||||
|
{% else %}{{ m }}{% endif %}
|
||||||
|
<button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module">
|
||||||
|
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
<button onclick="openAddModuleModal()" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border border-dashed border-gray-400 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-orange-400 hover:text-orange-500 transition-colors">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
|
Add Module
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% if project.data_collection_mode == 'remote' %}
|
{% if project.data_collection_mode == 'remote' %}
|
||||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -31,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Project Actions -->
|
<!-- Project Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
{% if 'sound_monitoring' in modules %}
|
||||||
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
||||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-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">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -40,6 +75,346 @@
|
|||||||
Generate Combined Report
|
Generate Combined Report
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button onclick="openMergeModal()"
|
||||||
|
title="Merge this project into another (consolidates duplicates)"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||||
|
</svg>
|
||||||
|
Merge into…
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Merge Modal —
|
||||||
|
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 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 -->
|
||||||
|
<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 min-h-[320px]">
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
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"
|
||||||
|
data-target-id="${_mergeEsc(m.id)}"
|
||||||
|
data-target-name="${_mergeEsc(m.name)}"
|
||||||
|
onmousedown="event.preventDefault()"
|
||||||
|
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">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
||||||
|
${metaLine}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
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) {
|
||||||
|
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">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add Module</h3>
|
||||||
|
<button onclick="closeAddModuleModal()" 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>
|
||||||
|
<div id="add-module-options" class="space-y-2">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</div>
|
||||||
|
<p id="add-module-none" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-4">All available modules are already enabled.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _MODULE_META = {
|
||||||
|
sound_monitoring: { name: "Sound Monitoring", color: "orange", icon: "M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072" },
|
||||||
|
vibration_monitoring: { name: "Vibration Monitoring", color: "blue", icon: "M22 12h-4l-3 9L9 3l-3 9H2" },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function openAddModuleModal() {
|
||||||
|
const resp = await fetch(`/api/projects/${projectId}/modules`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const container = document.getElementById('add-module-options');
|
||||||
|
const none = document.getElementById('add-module-none');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!data.available || data.available.length === 0) {
|
||||||
|
none.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
none.classList.add('hidden');
|
||||||
|
data.available.forEach(m => {
|
||||||
|
const meta = _MODULE_META[m.module_type] || { name: m.module_type, color: 'gray' };
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `w-full text-left px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-${meta.color}-400 hover:bg-${meta.color}-50 dark:hover:bg-${meta.color}-900/20 transition-colors flex items-center gap-3`;
|
||||||
|
btn.innerHTML = `<span class="flex-1 font-medium text-gray-900 dark:text-white">${meta.name}</span>`;
|
||||||
|
btn.onclick = () => addModule(m.module_type);
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('add-module-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddModuleModal() {
|
||||||
|
document.getElementById('add-module-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addModule(moduleType) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: moduleType }),
|
||||||
|
});
|
||||||
|
closeAddModuleModal();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModule(moduleType) {
|
||||||
|
const meta = _MODULE_META[moduleType] || { name: moduleType };
|
||||||
|
if (!confirm(`Remove the ${meta.name} module? The data will not be deleted, but the related tabs will be hidden.`)) return;
|
||||||
|
await fetch(`/api/projects/${projectId}/modules/${moduleType}`, { method: 'DELETE' });
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.project.status == 'active' %}
|
{% if item.project.status == 'upcoming' %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||||
|
{% elif item.project.status == 'active' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
{% elif item.project.status == 'on_hold' %}
|
{% elif item.project.status == 'on_hold' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% set s = item.session %}
|
{% set s = item.session %}
|
||||||
{% set loc = item.location %}
|
{% set loc = item.location %}
|
||||||
{% set unit = item.unit %}
|
{% set unit = item.unit %}
|
||||||
|
{% set effective_range = item.effective_range %}
|
||||||
|
|
||||||
{# Period display maps #}
|
{# Period display maps #}
|
||||||
{% set period_labels = {
|
{% set period_labels = {
|
||||||
@@ -49,25 +50,74 @@
|
|||||||
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Period type badge (click to change) -->
|
<!-- Period type badge (click to open hour editor) -->
|
||||||
<div class="relative" id="period-wrap-{{ s.id }}">
|
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||||
<button onclick="togglePeriodMenu('{{ s.id }}')"
|
<button onclick="openPeriodEditor('{{ s.id }}')"
|
||||||
id="period-badge-{{ s.id }}"
|
id="period-badge-{{ s.id }}"
|
||||||
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
title="Click to change period type">
|
title="Click to edit period type and hours">
|
||||||
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 opacity-60 shrink-0" 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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="period-menu-{{ s.id }}"
|
|
||||||
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
<!-- Period editor panel -->
|
||||||
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
<div id="period-editor-{{ s.id }}"
|
||||||
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
|
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg w-64 p-3 space-y-3">
|
||||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
|
||||||
{{ pt_label }}
|
<!-- Period type selector -->
|
||||||
</button>
|
<div>
|
||||||
{% endfor %}
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Period Type</label>
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','WD Day'),('weekday_night','WD Night'),('weekend_day','WE Day'),('weekend_night','WE Night')] %}
|
||||||
|
<button onclick="selectPeriodType('{{ s.id }}', '{{ pt }}')"
|
||||||
|
id="pt-btn-{{ s.id }}-{{ pt }}"
|
||||||
|
class="period-type-btn text-xs py-1 px-2 rounded border transition-colors
|
||||||
|
{% if s.period_type == pt %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
|
||||||
|
{{ pt_label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hour inputs -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (0–23)</label>
|
||||||
|
<input type="number" min="0" max="23"
|
||||||
|
id="period-start-hr-{{ s.id }}"
|
||||||
|
value="{{ s.period_start_hour if s.period_start_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 19"
|
||||||
|
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (0–23)</label>
|
||||||
|
<input type="number" min="0" max="23"
|
||||||
|
id="period-end-hr-{{ s.id }}"
|
||||||
|
value="{{ s.period_end_hour if s.period_end_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 7"
|
||||||
|
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">Day: 7→19 · Night: 19→7 · Customize as needed</p>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button onclick="savePeriodEditor('{{ s.id }}')"
|
||||||
|
class="flex-1 text-xs py-1 bg-seismo-orange text-white rounded hover:bg-orange-600 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onclick="closePeriodEditor('{{ s.id }}')"
|
||||||
|
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="clearPeriodEditor('{{ s.id }}')"
|
||||||
|
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
|
||||||
|
title="Clear period type and hours">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,8 +181,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if s.notes %}
|
<!-- Effective window (when period hours are set) -->
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
|
{% if effective_range %}
|
||||||
|
<div class="flex items-center gap-1 mt-1.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||||
|
<span id="effective-range-{{ s.id }}">Effective: {{ effective_range }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="hidden text-xs text-indigo-600 dark:text-indigo-400 mt-1.5"
|
||||||
|
id="effective-range-{{ s.id }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,6 +222,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
|
||||||
const PERIOD_COLORS = {
|
const PERIOD_COLORS = {
|
||||||
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
@@ -175,46 +236,145 @@ const PERIOD_LABELS = {
|
|||||||
weekend_day: 'Weekend Day',
|
weekend_day: 'Weekend Day',
|
||||||
weekend_night: 'Weekend Night',
|
weekend_night: 'Weekend Night',
|
||||||
};
|
};
|
||||||
|
// Default hours for each period type
|
||||||
|
const PERIOD_DEFAULT_HOURS = {
|
||||||
|
weekday_day: {start: 7, end: 19},
|
||||||
|
weekday_night: {start: 19, end: 7},
|
||||||
|
weekend_day: {start: 7, end: 19},
|
||||||
|
weekend_night: {start: 19, end: 7},
|
||||||
|
};
|
||||||
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
||||||
const ALL_BADGE_COLORS = [...new Set([
|
const ALL_BADGE_COLORS = [...new Set([
|
||||||
...FALLBACK_COLORS,
|
...FALLBACK_COLORS,
|
||||||
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
||||||
])];
|
])];
|
||||||
|
|
||||||
function togglePeriodMenu(sessionId) {
|
// Track which period type is selected in the editor before saving
|
||||||
const menu = document.getElementById('period-menu-' + sessionId);
|
const _editorState = {};
|
||||||
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
|
|
||||||
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
|
// ---- Period editor ----
|
||||||
|
|
||||||
|
function openPeriodEditor(sessionId) {
|
||||||
|
// Close all other editors first
|
||||||
|
document.querySelectorAll('[id^="period-editor-"]').forEach(el => {
|
||||||
|
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
|
||||||
});
|
});
|
||||||
menu.classList.toggle('hidden');
|
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closePeriodEditor(sessionId) {
|
||||||
|
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
|
||||||
|
delete _editorState[sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPeriodType(sessionId, pt) {
|
||||||
|
_editorState[sessionId] = pt;
|
||||||
|
// Highlight selected button
|
||||||
|
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||||
|
const isSelected = btn.id === `pt-btn-${sessionId}-${pt}`;
|
||||||
|
btn.classList.toggle('border-seismo-orange', isSelected);
|
||||||
|
btn.classList.toggle('bg-orange-50', isSelected);
|
||||||
|
btn.classList.toggle('text-seismo-orange', isSelected);
|
||||||
|
btn.classList.toggle('dark:bg-orange-900/20', isSelected);
|
||||||
|
btn.classList.toggle('border-gray-200', !isSelected);
|
||||||
|
btn.classList.toggle('dark:border-gray-600', !isSelected);
|
||||||
|
btn.classList.toggle('text-gray-600', !isSelected);
|
||||||
|
btn.classList.toggle('dark:text-gray-400', !isSelected);
|
||||||
|
});
|
||||||
|
// Fill default hours
|
||||||
|
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||||
|
if (defaults) {
|
||||||
|
const sh = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const eh = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
if (sh && !sh.value) sh.value = defaults.start;
|
||||||
|
if (eh && !eh.value) eh.value = defaults.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePeriodEditor(sessionId) {
|
||||||
|
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
|
||||||
|
?.dataset?.currentPeriod || null;
|
||||||
|
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
if (pt !== undefined) payload.period_type = pt || null;
|
||||||
|
payload.period_start_hour = shInput?.value !== '' ? parseInt(shInput.value, 10) : null;
|
||||||
|
payload.period_end_hour = ehInput?.value !== '' ? parseInt(ehInput.value, 10) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
// Update badge
|
||||||
|
const badge = document.getElementById('period-badge-' + sessionId);
|
||||||
|
const label = document.getElementById('period-label-' + sessionId);
|
||||||
|
const newPt = result.period_type;
|
||||||
|
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||||
|
if (newPt && PERIOD_COLORS[newPt]) {
|
||||||
|
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
|
||||||
|
if (label) label.textContent = PERIOD_LABELS[newPt];
|
||||||
|
} else {
|
||||||
|
badge.classList.add(...FALLBACK_COLORS);
|
||||||
|
if (label) label.textContent = 'Set period';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update effective range display
|
||||||
|
_updateEffectiveRange(sessionId, result.period_start_hour, result.period_end_hour);
|
||||||
|
|
||||||
|
closePeriodEditor(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to save period: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPeriodEditor(sessionId) {
|
||||||
|
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
if (shInput) shInput.value = '';
|
||||||
|
if (ehInput) ehInput.value = '';
|
||||||
|
_editorState[sessionId] = null;
|
||||||
|
|
||||||
|
// Reset period type button highlights
|
||||||
|
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||||
|
btn.classList.remove('border-seismo-orange','bg-orange-50','text-seismo-orange','dark:bg-orange-900/20');
|
||||||
|
btn.classList.add('border-gray-200','dark:border-gray-600','text-gray-600','dark:text-gray-400');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Effective range helper ----
|
||||||
|
|
||||||
|
function _updateEffectiveRange(sessionId, startHour, endHour) {
|
||||||
|
const el = document.getElementById('effective-range-' + sessionId);
|
||||||
|
if (!el) return;
|
||||||
|
if (startHour == null || endHour == null) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
function _fmt(h) {
|
||||||
|
const ampm = h < 12 ? 'AM' : 'PM';
|
||||||
|
const h12 = h % 12 || 12;
|
||||||
|
return `${h12}:00 ${ampm}`;
|
||||||
|
}
|
||||||
|
// We don't have the session start date in JS so just show the hours pattern
|
||||||
|
el.textContent = `Effective window: ${_fmt(startHour)} → ${_fmt(endHour)}`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Close editors on outside click ----
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (!e.target.closest('[id^="period-wrap-"]')) {
|
if (!e.target.closest('[id^="period-wrap-"]')) {
|
||||||
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
|
document.querySelectorAll('[id^="period-editor-"]').forEach(m => m.classList.add('hidden'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function setPeriodType(sessionId, periodType) {
|
// ---- Label editing ----
|
||||||
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
|
|
||||||
const badge = document.getElementById('period-badge-' + sessionId);
|
|
||||||
badge.disabled = true;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({period_type: periodType}),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error(await resp.text());
|
|
||||||
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
|
||||||
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
|
|
||||||
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
|
||||||
} catch(err) {
|
|
||||||
alert('Failed to update period type: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
badge.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditLabel(sessionId) {
|
function startEditLabel(sessionId) {
|
||||||
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
||||||
@@ -234,7 +394,7 @@ async function saveLabel(sessionId) {
|
|||||||
const input = document.getElementById('label-input-' + sessionId);
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
const newLabel = input.value.trim();
|
const newLabel = input.value.trim();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({session_label: newLabel}),
|
body: JSON.stringify({session_label: newLabel}),
|
||||||
@@ -249,8 +409,10 @@ async function saveLabel(sessionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Session details ----
|
||||||
|
|
||||||
function viewSession(sessionId) {
|
function viewSession(sessionId) {
|
||||||
alert('Session details coming soon: ' + sessionId);
|
window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!-- Monthly Sessions Calendar — Gantt Style -->
|
||||||
|
<div class="sessions-cal-wrap bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Month navigation -->
|
||||||
|
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ prev_month }}&year={{ prev_year }}"
|
||||||
|
hx-target="#sessions-calendar"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||||
|
title="Previous month">
|
||||||
|
<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="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ month_name }} {{ year }}</h3>
|
||||||
|
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ next_month }}&year={{ next_year }}"
|
||||||
|
hx-target="#sessions-calendar"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||||
|
title="Next month">
|
||||||
|
<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 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend + key -->
|
||||||
|
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center gap-x-5 gap-y-1.5">
|
||||||
|
{% if legend %}
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{% for loc in legend %}
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ loc.color }}"></span>
|
||||||
|
<span>{{ loc.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Bar key -->
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 ml-auto shrink-0">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-8 h-2 rounded-sm" style="background:rgba(100,100,100,0.25)"></div>
|
||||||
|
<span>Device on</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-8 h-2 rounded-sm bg-blue-500"></div>
|
||||||
|
<span>Effective window</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day-of-week headers -->
|
||||||
|
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
|
||||||
|
<div class="py-2 text-center text-xs font-medium uppercase tracking-wide
|
||||||
|
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% else %}text-gray-400 dark:text-gray-500{% endif %}">
|
||||||
|
{{ day_name }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
{% for week in weeks %}
|
||||||
|
<div class="grid grid-cols-7 {% if not loop.last %}border-b border-gray-100 dark:border-gray-700{% endif %}">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="min-h-[80px] p-1.5
|
||||||
|
{% if not loop.last %}border-r border-gray-100 dark:border-gray-700{% endif %}
|
||||||
|
{% if not day.in_month %}bg-gray-50 dark:bg-slate-800/50{% else %}bg-white dark:bg-slate-800{% endif %}
|
||||||
|
{% if day.is_today %}ring-1 ring-inset ring-seismo-orange{% endif %}">
|
||||||
|
|
||||||
|
<!-- Date number -->
|
||||||
|
<div class="text-right mb-1.5">
|
||||||
|
<span class="text-xs {% if day.is_today %}font-bold text-seismo-orange{% elif day.in_month %}text-gray-700 dark:text-gray-300{% else %}text-gray-300 dark:text-gray-600{% endif %}">
|
||||||
|
{{ day.date.day }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gantt bars -->
|
||||||
|
{% if day.sessions %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for s in day.sessions %}
|
||||||
|
|
||||||
|
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" class="block">
|
||||||
|
|
||||||
|
<!-- 24-hour timeline bar -->
|
||||||
|
<div class="relative overflow-hidden -mx-1.5" style="height:11px; background:rgba(128,128,128,0.08);">
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:25%; background:rgba(128,128,128,0.18)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:50%; background:rgba(128,128,128,0.28)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:75%; background:rgba(128,128,128,0.18)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0"
|
||||||
|
style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;"></div>
|
||||||
|
{% if s.eff_start_pct is not none %}
|
||||||
|
<div class="absolute"
|
||||||
|
style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="truncate mt-0.5" style="color:{{ s.color }}; font-size:0.58rem; line-height:1.3;">
|
||||||
|
{{ s.location_name }} · {{ day.date.strftime('%-m/%-d') }} · {% if s.period_type %}{{ 'Night' if 'night' in s.period_type else 'Day' }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Time scale footer hint -->
|
||||||
|
<div class="px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 flex justify-between text-gray-300 dark:text-gray-600" style="font-size:0.6rem;">
|
||||||
|
<span>12 AM</span>
|
||||||
|
<span>6 AM</span>
|
||||||
|
<span>12 PM</span>
|
||||||
|
<span>6 PM</span>
|
||||||
|
<span>12 AM</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -56,6 +56,15 @@
|
|||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
{{ session.status or 'unknown' }}
|
{{ session.status or 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Edit Session Times -->
|
||||||
|
<button onclick="event.stopPropagation(); openEditSessionModal('{{ session.id }}', '{{ session.started_at|local_datetime if session.started_at else '' }}', '{{ session.stopped_at|local_datetime if session.stopped_at else '' }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
|
||||||
|
title="Edit session times">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<!-- Download All Files in Session -->
|
<!-- Download All Files in Session -->
|
||||||
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
||||||
@@ -326,4 +335,74 @@ async function deleteSession(sessionId) {
|
|||||||
alert(`Error deleting session: ${error.message}`);
|
alert(`Error deleting session: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditSessionModal(sessionId, startedAt, stoppedAt) {
|
||||||
|
document.getElementById('editSessionId').value = sessionId;
|
||||||
|
// local_datetime filter returns "YYYY-MM-DD HH:MM" — convert to "YYYY-MM-DDTHH:MM" for datetime-local input
|
||||||
|
document.getElementById('editStartedAt').value = startedAt ? startedAt.replace(' ', 'T') : '';
|
||||||
|
document.getElementById('editStoppedAt').value = stoppedAt ? stoppedAt.replace(' ', 'T') : '';
|
||||||
|
document.getElementById('editSessionModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditSessionModal() {
|
||||||
|
document.getElementById('editSessionModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSessionTimes() {
|
||||||
|
const sessionId = document.getElementById('editSessionId').value;
|
||||||
|
const startedAt = document.getElementById('editStartedAt').value;
|
||||||
|
const stoppedAt = document.getElementById('editStoppedAt').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
started_at: startedAt || null,
|
||||||
|
stopped_at: stoppedAt || null,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeEditSessionModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Failed to update session: ${data.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error updating session: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Edit Session Times Modal -->
|
||||||
|
<div id="editSessionModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Session Times</h3>
|
||||||
|
<input type="hidden" id="editSessionId">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
|
||||||
|
<input type="datetime-local" id="editStartedAt"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stop Time</label>
|
||||||
|
<input type="datetime-local" id="editStoppedAt"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Times are in your local timezone. The session label and period type will be updated automatically.</p>
|
||||||
|
<div class="flex justify-end gap-3 mt-5">
|
||||||
|
<button onclick="closeEditSessionModal()"
|
||||||
|
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="saveSessionTimes()"
|
||||||
|
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{# Project-wide vibration events roll-up. Loaded via HTMX. #}
|
||||||
|
{% if summary.vibration_location_count == 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No vibration monitoring locations yet. Add one to start collecting events.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Project-wide vibration events
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
across {{ summary.vibration_location_count }} location{{ '' if summary.vibration_location_count == 1 else 's' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI tiles -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||||
|
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "{:,}".format(summary.total_events) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">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 }}"
|
||||||
|
class="text-xs text-seismo-orange hover:text-seismo-navy truncate mt-1"
|
||||||
|
title="{{ summary.peak_pvs_location_name }}">
|
||||||
|
{{ summary.peak_pvs_location_name }}
|
||||||
|
{% if summary.peak_pvs_at %} · {{ summary.peak_pvs_at[:10] }}{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||||
|
{% if summary.last_event %}
|
||||||
|
<span class="text-base font-bold text-gray-900 dark:text-white mt-1">{{ summary.last_event[:19].replace('T', ' ') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
|
||||||
|
<span class="text-2xl font-bold {% if summary.false_trigger_count > 0 %}text-amber-600 dark:text-amber-400{% else %}text-gray-900 dark:text-white{% endif %} mt-1">{{ "{:,}".format(summary.false_trigger_count) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if summary.per_location and summary.total_events > 0 %}
|
||||||
|
<!-- Top locations by activity -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Top locations by activity</h4>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{% for loc in summary.per_location[:5] %}
|
||||||
|
<a href="/projects/{{ summary.project_id }}/nrl/{{ loc.location_id }}"
|
||||||
|
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
📍 {{ loc.location_name }}
|
||||||
|
{% if loc.removed_at %}
|
||||||
|
<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold align-middle"
|
||||||
|
title="Location no longer actively monitored — events shown are historical">removed</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
||||||
|
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
||||||
|
{% if loc.peak_pvs is not none %}
|
||||||
|
<span class="text-xs text-gray-500">peak {{ "%.4f"|format(loc.peak_pvs) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<tr id="seismo-row-{{ unit.id }}" class="bg-blue-50 dark:bg-slate-600 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap">
|
||||||
|
<select name="status"
|
||||||
|
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||||
|
<option value="deployed" {% if unit.deployed %}selected{% endif %}>Deployed</option>
|
||||||
|
<option value="out_for_calibration" {% if unit.out_for_calibration %}selected{% endif %}>Out for Cal</option>
|
||||||
|
<option value="benched" {% if not unit.deployed and not unit.out_for_calibration %}selected{% endif %}>Benched</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.address %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<span>{{ unit.coordinates }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap">
|
||||||
|
<input type="date"
|
||||||
|
name="last_calibrated"
|
||||||
|
value="{{ unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '' }}"
|
||||||
|
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<input type="text"
|
||||||
|
name="note"
|
||||||
|
value="{{ unit.note or '' }}"
|
||||||
|
placeholder="Add a note..."
|
||||||
|
class="w-full text-sm rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap text-right text-sm">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button hx-post="/api/seismo-dashboard/unit/{{ unit.id }}/quick-update"
|
||||||
|
hx-include="closest tr"
|
||||||
|
hx-target="#seismo-row-{{ unit.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/view-row"
|
||||||
|
hx-target="#seismo-row-{{ unit.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<tr id="seismo-row-{{ unit.id }}" class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Deployed
|
||||||
|
</span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Out for Cal
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.address %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.last_calibrated %}
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
{% if unit.next_calibration_due and today %}
|
||||||
|
{% set days_until = (unit.next_calibration_due - today).days %}
|
||||||
|
{% if days_until < 0 %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
||||||
|
{% elif days_until <= 14 %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
||||||
|
{% endif %}
|
||||||
|
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
{% if unit.note %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/edit-row"
|
||||||
|
hx-target="#seismo-row-{{ unit.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title="Edit row">
|
||||||
|
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||||
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||||
|
{% if out_for_calibration > 0 %}
|
||||||
|
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ out_for_calibration }} out for calibration</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
|||||||
@@ -92,81 +92,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
{% include "partials/seismo_row_view.html" %}
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
|
||||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
{{ unit.id }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
|
||||||
{% if unit.deployed %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Deployed
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Benched
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.deployed_with_modem_id %}
|
|
||||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
{{ unit.deployed_with_modem_id }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.address %}
|
|
||||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
|
||||||
{% elif unit.coordinates %}
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.last_calibrated %}
|
|
||||||
<span class="inline-flex items-center gap-1.5">
|
|
||||||
{% if unit.next_calibration_due and today %}
|
|
||||||
{% set days_until = (unit.next_calibration_due - today).days %}
|
|
||||||
{% if days_until < 0 %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
|
||||||
{% elif days_until <= 14 %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
|
||||||
{% endif %}
|
|
||||||
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
|
||||||
{% if unit.note %}
|
|
||||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
|
||||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
View Details →
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+545
-280
File diff suppressed because it is too large
Load Diff
+146
-116
@@ -36,12 +36,17 @@
|
|||||||
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
||||||
<button onclick="switchTab('all')"
|
<button onclick="switchTab('all')"
|
||||||
id="tab-all"
|
id="tab-all"
|
||||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
All Projects
|
All Projects
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('upcoming')"
|
||||||
|
id="tab-upcoming"
|
||||||
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
|
Upcoming
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('active')"
|
<button onclick="switchTab('active')"
|
||||||
id="tab-active"
|
id="tab-active"
|
||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||||
Active
|
Active
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('on_hold')"
|
<button onclick="switchTab('on_hold')"
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
<!-- Projects List -->
|
<!-- Projects List -->
|
||||||
<div id="projects-list"
|
<div id="projects-list"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||||
hx-get="/api/projects/list"
|
hx-get="/api/projects/list?status=active"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<!-- Loading skeletons -->
|
<!-- Loading skeletons -->
|
||||||
@@ -91,124 +96,122 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6" id="createProjectContent">
|
<div class="p-6" id="createProjectContent">
|
||||||
<!-- Step 1: Project Type Selection (initially shown) -->
|
<form id="createProjectFormElement">
|
||||||
<div id="projectTypeSelection">
|
<div class="space-y-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Choose Project Type</h3>
|
<div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
hx-get="/api/projects/types/list"
|
Project Name <span class="text-red-500">*</span>
|
||||||
hx-trigger="load"
|
</label>
|
||||||
hx-target="this"
|
<input type="text"
|
||||||
hx-swap="innerHTML">
|
name="name"
|
||||||
<!-- Project type cards will be loaded here -->
|
required
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
</div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 flex justify-end">
|
|
||||||
<button type="button" onclick="hideCreateProjectModal()"
|
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div id="projectDetailsForm" class="hidden">
|
|
||||||
<button onclick="backToTypeSelection()"
|
|
||||||
class="mb-4 text-seismo-orange hover:text-seismo-navy">
|
|
||||||
← Back to project types
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<form id="createProjectFormElement"
|
|
||||||
hx-post="/api/projects/create"
|
|
||||||
hx-swap="none">
|
|
||||||
<input type="hidden" id="project_type_id" name="project_type_id">
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
Project Name *
|
Project Number
|
||||||
|
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="name"
|
name="project_number"
|
||||||
required
|
pattern="\d{4}-\d{2}"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
placeholder="2567-23"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
Description
|
Client Name
|
||||||
</label>
|
</label>
|
||||||
<textarea name="description"
|
<input type="text"
|
||||||
rows="3"
|
name="client_name"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<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">
|
Description
|
||||||
Client Name
|
</label>
|
||||||
</label>
|
<textarea name="description"
|
||||||
<input type="text"
|
rows="2"
|
||||||
name="client_name"
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Site Address
|
|
||||||
</label>
|
|
||||||
<input type="text"
|
|
||||||
name="site_address"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Start Date
|
|
||||||
</label>
|
|
||||||
<input type="date"
|
|
||||||
name="start_date"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
End Date (Optional)
|
|
||||||
</label>
|
|
||||||
<input type="date"
|
|
||||||
name="end_date"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
Site Coordinates (Optional)
|
Site Address
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="site_address"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Site Coordinates
|
||||||
|
<span class="text-gray-400 font-normal">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="site_coordinates"
|
name="site_coordinates"
|
||||||
placeholder="40.7128,-74.0060"
|
placeholder="40.7128,-74.0060"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end space-x-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<button type="button"
|
<div>
|
||||||
onclick="hideCreateProjectModal()"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Start Date</label>
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
<input type="date" name="start_date"
|
||||||
Cancel
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</button>
|
</div>
|
||||||
<button type="submit"
|
<div>
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||||
Create Project
|
<input type="date" name="end_date"
|
||||||
</button>
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
<!-- Modules -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Add Modules
|
||||||
|
<span class="text-gray-400 font-normal">(optional — can be added later)</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-orange-400 has-[:checked]:border-orange-400 has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-orange-900/20 transition-colors">
|
||||||
|
<input type="checkbox" id="ov-module-sound" class="accent-seismo-orange">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Sound Monitoring</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-blue-400 has-[:checked]:border-blue-400 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20 transition-colors">
|
||||||
|
<input type="checkbox" id="ov-module-vibration" class="accent-blue-500">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Vibration Monitoring</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Seismographs, modems</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ov-create-error" class="hidden mt-3 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm"></div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end space-x-3">
|
||||||
|
<button type="button"
|
||||||
|
onclick="hideCreateProjectModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="ov-submit-btn"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
||||||
|
Create Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,31 +239,58 @@ function showCreateProjectModal() {
|
|||||||
document.getElementById('createProjectModal').classList.remove('hidden');
|
document.getElementById('createProjectModal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showCreateProjectModal() {
|
||||||
|
document.getElementById('createProjectModal').classList.remove('hidden');
|
||||||
|
document.getElementById('createProjectFormElement').reset();
|
||||||
|
document.getElementById('ov-create-error').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function hideCreateProjectModal() {
|
function hideCreateProjectModal() {
|
||||||
document.getElementById('createProjectModal').classList.add('hidden');
|
document.getElementById('createProjectModal').classList.add('hidden');
|
||||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
|
||||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProjectType(typeId, typeName) {
|
document.getElementById('createProjectFormElement').addEventListener('submit', async function(e) {
|
||||||
document.getElementById('project_type_id').value = typeId;
|
e.preventDefault();
|
||||||
document.getElementById('projectTypeSelection').classList.add('hidden');
|
const submitBtn = document.getElementById('ov-submit-btn');
|
||||||
document.getElementById('projectDetailsForm').classList.remove('hidden');
|
const errorDiv = document.getElementById('ov-create-error');
|
||||||
}
|
errorDiv.classList.add('hidden');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Creating...';
|
||||||
|
|
||||||
function backToTypeSelection() {
|
const formData = new FormData(this);
|
||||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
// project_type_id no longer required — send empty string so backend accepts it
|
||||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
formData.set('project_type_id', '');
|
||||||
}
|
|
||||||
|
|
||||||
// Handle form submission success
|
try {
|
||||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
const resp = await fetch('/api/projects/create', { method: 'POST', body: formData });
|
||||||
if (event.detail.elt.id === 'createProjectFormElement' && event.detail.successful) {
|
const result = await resp.json();
|
||||||
|
if (!resp.ok || !result.success) {
|
||||||
|
errorDiv.textContent = result.detail || result.message || 'Failed to create project';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projectId = result.project_id;
|
||||||
|
// Add selected modules
|
||||||
|
if (document.getElementById('ov-module-sound').checked) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: 'sound_monitoring' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.getElementById('ov-module-vibration').checked) {
|
||||||
|
await fetch(`/api/projects/${projectId}/modules`, {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ module_type: 'vibration_monitoring' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
hideCreateProjectModal();
|
hideCreateProjectModal();
|
||||||
// Refresh project list
|
|
||||||
htmx.ajax('GET', '/api/projects/list', {target: '#projects-list'});
|
htmx.ajax('GET', '/api/projects/list', {target: '#projects-list'});
|
||||||
// Show success message
|
} catch(err) {
|
||||||
alert('Project created successfully!');
|
errorDiv.textContent = `Error: ${err.message}`;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Create Project';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+61
-29
@@ -181,6 +181,27 @@ let chartInstance = null;
|
|||||||
let allData = [];
|
let allData = [];
|
||||||
let allHeaders = [];
|
let allHeaders = [];
|
||||||
|
|
||||||
|
// Session period window (null = no filtering)
|
||||||
|
const SESSION_PERIOD_START_HOUR = {{ period_start_hour if period_start_hour is not none else 'null' }};
|
||||||
|
const SESSION_PERIOD_END_HOUR = {{ period_end_hour if period_end_hour is not none else 'null' }};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given hour integer is within the session's period window.
|
||||||
|
* Always returns true when no period window is configured.
|
||||||
|
*/
|
||||||
|
function _isInPeriodWindow(hour) {
|
||||||
|
if (SESSION_PERIOD_START_HOUR === null || SESSION_PERIOD_END_HOUR === null) return true;
|
||||||
|
const sh = SESSION_PERIOD_START_HOUR;
|
||||||
|
const eh = SESSION_PERIOD_END_HOUR;
|
||||||
|
if (eh > sh) {
|
||||||
|
// Same-day window, e.g. 7–19
|
||||||
|
return hour >= sh && hour < eh;
|
||||||
|
} else {
|
||||||
|
// Crosses midnight, e.g. 19–7
|
||||||
|
return hour >= sh || hour < eh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load data on page load
|
// Load data on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadRndData();
|
loadRndData();
|
||||||
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(headers, data) {
|
function _rowHour(row) {
|
||||||
const headerRow = document.getElementById('table-header');
|
// Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
|
||||||
const tbody = document.getElementById('table-body');
|
const t = row['Start Time'];
|
||||||
|
if (!t) return null;
|
||||||
|
const parts = t.split(' ');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return parseInt(parts[1].split(':')[0], 10);
|
||||||
|
}
|
||||||
|
|
||||||
// Render headers
|
function _buildRow(headers, row) {
|
||||||
headerRow.innerHTML = '<tr>' + headers.map(h =>
|
const hour = _rowHour(row);
|
||||||
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
|
const inWindow = hour === null || _isInPeriodWindow(hour);
|
||||||
).join('') + '</tr>';
|
const dimClass = inWindow ? '' : 'opacity-40';
|
||||||
|
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
|
||||||
// Render rows (limit to first 500 for performance)
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
|
||||||
const displayData = data.slice(0, 500);
|
|
||||||
tbody.innerHTML = displayData.map(row =>
|
|
||||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
||||||
headers.map(h => {
|
headers.map(h => {
|
||||||
const val = row[h];
|
const val = row[h];
|
||||||
let displayVal = val;
|
let displayVal = val;
|
||||||
@@ -410,12 +433,34 @@ function renderTable(headers, data) {
|
|||||||
}
|
}
|
||||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</tr>'
|
'</tr>';
|
||||||
).join('');
|
}
|
||||||
|
|
||||||
|
function renderTable(headers, data) {
|
||||||
|
const headerRow = document.getElementById('table-header');
|
||||||
|
const tbody = document.getElementById('table-body');
|
||||||
|
|
||||||
|
// Render headers — add period window indicator if configured
|
||||||
|
let periodNote = '';
|
||||||
|
if (SESSION_PERIOD_START_HOUR !== null && SESSION_PERIOD_END_HOUR !== null) {
|
||||||
|
function _fmtH(h) { const ampm = h < 12 ? 'AM' : 'PM'; return `${h%12||12}:00 ${ampm}`; }
|
||||||
|
periodNote = ` <span class="ml-2 text-indigo-500 dark:text-indigo-400 font-normal normal-case text-xs" title="Dimmed rows are outside this window">Period: ${_fmtH(SESSION_PERIOD_START_HOUR)} → ${_fmtH(SESSION_PERIOD_END_HOUR)}</span>`;
|
||||||
|
}
|
||||||
|
headerRow.innerHTML = '<tr>' + headers.map((h, i) =>
|
||||||
|
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}${i === 0 ? periodNote : ''}</th>`
|
||||||
|
).join('') + '</tr>';
|
||||||
|
|
||||||
|
// Render rows (limit to first 500 for performance)
|
||||||
|
const displayData = data.slice(0, 500);
|
||||||
|
tbody.innerHTML = displayData.map(row => _buildRow(headers, row)).join('');
|
||||||
|
|
||||||
// Update row count
|
// Update row count
|
||||||
|
const inWindowCount = data.filter(r => { const h = _rowHour(r); return h === null || _isInPeriodWindow(h); }).length;
|
||||||
|
const windowNote = (SESSION_PERIOD_START_HOUR !== null && inWindowCount < data.length)
|
||||||
|
? ` (${inWindowCount} in period window)`
|
||||||
|
: '';
|
||||||
document.getElementById('row-count').textContent =
|
document.getElementById('row-count').textContent =
|
||||||
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
|
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows${windowNote}` : `${data.length.toLocaleString()} rows${windowNote}`;
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
document.getElementById('table-search').addEventListener('input', function(e) {
|
document.getElementById('table-search').addEventListener('input', function(e) {
|
||||||
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const displayFiltered = filtered.slice(0, 500);
|
const displayFiltered = filtered.slice(0, 500);
|
||||||
tbody.innerHTML = displayFiltered.map(row =>
|
tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
|
||||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
||||||
headers.map(h => {
|
|
||||||
const val = row[h];
|
|
||||||
let displayVal = val;
|
|
||||||
if (val === null || val === undefined) {
|
|
||||||
displayVal = '-';
|
|
||||||
} else if (typeof val === 'number') {
|
|
||||||
displayVal = val.toFixed(1);
|
|
||||||
}
|
|
||||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
|
||||||
}).join('') +
|
|
||||||
'</tr>'
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
document.getElementById('row-count').textContent =
|
document.getElementById('row-count').textContent =
|
||||||
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
||||||
|
|||||||
+121
-8
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}Devices - Seismo Fleet Manager{% endblock %}
|
{% block title %}Devices - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "partials/fleet_tab_strip.html" %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
||||||
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
||||||
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
||||||
|
<button class="filter-btn filter-status" data-value="allocated">Allocated</button>
|
||||||
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
||||||
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1230,12 +1232,22 @@
|
|||||||
|
|
||||||
// Refresh device list (applies current client-side filters after load)
|
// Refresh device list (applies current client-side filters after load)
|
||||||
function refreshDeviceList() {
|
function refreshDeviceList() {
|
||||||
|
const scrollY = window.scrollY;
|
||||||
htmx.ajax('GET', '/partials/devices-all', {
|
htmx.ajax('GET', '/partials/devices-all', {
|
||||||
target: '#device-content',
|
target: '#device-content',
|
||||||
swap: 'innerHTML'
|
swap: 'innerHTML'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
// Re-apply filters after content loads
|
setTimeout(() => {
|
||||||
setTimeout(filterDevices, 100);
|
filterDevices();
|
||||||
|
// Re-apply sort if one was active
|
||||||
|
if (window.currentSort && window.currentSort.column) {
|
||||||
|
// sortTable toggles direction, so pre-flip so the toggle lands on the correct value
|
||||||
|
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
sortTable(window.currentSort.column);
|
||||||
|
}
|
||||||
|
// Restore scroll position
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,13 +1260,114 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Silently refresh only the status/age/last-seen fields in the existing rows
|
||||||
|
// without touching the DOM structure, so sort/scroll/filters are undisturbed.
|
||||||
|
async function refreshStatusInPlace() {
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/roster/status-snapshot');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
snapshot = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = snapshot.units || {};
|
||||||
|
|
||||||
|
// Helper: map status string → dot color class
|
||||||
|
function statusDotClass(status) {
|
||||||
|
if (status === 'OK') return 'bg-green-500';
|
||||||
|
if (status === 'Pending') return 'bg-yellow-500';
|
||||||
|
if (status === 'Missing') return 'bg-red-500';
|
||||||
|
return 'bg-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Desktop table rows ---
|
||||||
|
document.querySelectorAll('#roster-tbody tr[data-id]').forEach(row => {
|
||||||
|
const uid = row.dataset.id;
|
||||||
|
const u = units[uid];
|
||||||
|
if (!u) return;
|
||||||
|
|
||||||
|
const newStatus = u.status || 'Missing';
|
||||||
|
const newAge = u.age || 'N/A';
|
||||||
|
const newLast = u.last || '';
|
||||||
|
|
||||||
|
// Update data attributes used by sort/filter
|
||||||
|
row.dataset.health = newStatus;
|
||||||
|
row.dataset.age = newAge;
|
||||||
|
row.dataset.lastSeen = newLast;
|
||||||
|
|
||||||
|
// Status dot (first span inside first td)
|
||||||
|
const dot = row.querySelector('td:first-child span:first-child');
|
||||||
|
if (dot && row.dataset.status === 'deployed') {
|
||||||
|
dot.className = `w-3 h-3 rounded-full ${statusDotClass(newStatus)}`;
|
||||||
|
dot.title = newStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age cell (6th td — index 5)
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells[5]) {
|
||||||
|
const ageDiv = cells[5].querySelector('div');
|
||||||
|
if (ageDiv) {
|
||||||
|
ageDiv.textContent = newAge;
|
||||||
|
ageDiv.className = `text-sm ${
|
||||||
|
newStatus === 'Missing' ? 'text-red-600 dark:text-red-400 font-semibold' :
|
||||||
|
newStatus === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
|
'text-gray-500 dark:text-gray-400'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-seen cell (5th td — index 4)
|
||||||
|
if (cells[4]) {
|
||||||
|
const lsDiv = cells[4].querySelector('.last-seen-cell');
|
||||||
|
if (lsDiv) {
|
||||||
|
lsDiv.dataset.iso = newLast;
|
||||||
|
lsDiv.textContent = newLast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Mobile cards ---
|
||||||
|
document.querySelectorAll('.device-card[data-unit-id]').forEach(card => {
|
||||||
|
const uid = card.dataset.unitId;
|
||||||
|
const u = units[uid];
|
||||||
|
if (!u) return;
|
||||||
|
|
||||||
|
const newStatus = u.status || 'Missing';
|
||||||
|
const newAge = u.age || 'N/A';
|
||||||
|
|
||||||
|
card.dataset.health = newStatus;
|
||||||
|
card.dataset.age = newAge;
|
||||||
|
|
||||||
|
// Status dot (first span in header div)
|
||||||
|
const dot = card.querySelector('span.rounded-full:first-child');
|
||||||
|
if (dot && card.dataset.status === 'deployed') {
|
||||||
|
dot.className = `w-4 h-4 rounded-full ${statusDotClass(newStatus)}`;
|
||||||
|
dot.title = newStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age text — the div containing the clock emoji
|
||||||
|
card.querySelectorAll('.text-sm').forEach(div => {
|
||||||
|
if (div.textContent.includes('🕐')) {
|
||||||
|
div.textContent = `🕐 ${newAge}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the "last updated" timestamp
|
||||||
|
const ts = document.getElementById('last-updated');
|
||||||
|
if (ts) ts.textContent = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
// Re-apply active filters (sort order is untouched since DOM rows weren't moved)
|
||||||
|
filterDevices();
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
// Poll status every 30 seconds — updates cells in-place, no DOM restructuring
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const deviceContent = document.getElementById('device-content');
|
if (!isAnyModalOpen()) {
|
||||||
if (deviceContent && !isAnyModalOpen()) {
|
refreshStatusInPlace();
|
||||||
// Only auto-refresh if no modal is open
|
|
||||||
refreshDeviceList();
|
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
@@ -1352,7 +1465,7 @@
|
|||||||
|
|
||||||
// Toggle health filter visibility (hide for retired/ignored)
|
// Toggle health filter visibility (hide for retired/ignored)
|
||||||
const healthGroup = document.getElementById('health-filter-group');
|
const healthGroup = document.getElementById('health-filter-group');
|
||||||
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') {
|
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored' || this.dataset.value === 'allocated') {
|
||||||
healthGroup.style.display = 'none';
|
healthGroup.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
healthGroup.style.display = 'flex';
|
healthGroup.style.display = 'flex';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "partials/fleet_tab_strip.html" %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
<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>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
<option value="deployed">Deployed</option>
|
<option value="deployed">Deployed</option>
|
||||||
<option value="benched">Benched</option>
|
<option value="benched">Benched</option>
|
||||||
|
<option value="out_for_calibration">Out for Calibration</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Modem Filter -->
|
<!-- Modem Filter -->
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ session.session_label or 'Session' }} — {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
|
||||||
|
<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 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name }}</a>
|
||||||
|
<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 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white truncate max-w-xs">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<svg class="w-7 h-7 text-seismo-orange" 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>
|
||||||
|
<span id="header-label">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||||
|
</h1>
|
||||||
|
{% if location %}
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ location.name }}{% if unit %} · {{ unit.id }}{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if session.status == 'completed' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
|
||||||
|
{% elif session.status == 'recording' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span> Recording
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- LEFT COLUMN: Info + Edit -->
|
||||||
|
<div class="lg:col-span-1 space-y-4">
|
||||||
|
|
||||||
|
<!-- Session Info Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Session Info</h2>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Label</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right max-w-[180px] truncate"
|
||||||
|
id="info-label">{{ session.session_label or '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ location.name if location else '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Period</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white" id="info-period">
|
||||||
|
{% set PLABELS = {'weekday_day':'Weekday Day','weekday_night':'Weekday Night','weekend_day':'Weekend Day','weekend_night':'Weekend Night'} %}
|
||||||
|
{{ PLABELS.get(session.period_type, '—') }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if effective_range %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||||
|
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective">{{ effective_range }}</dd>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex justify-between hidden" id="info-effective-row">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||||
|
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
|
||||||
|
{{ report_date or '— (auto)' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if session.started_at %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.started_at|local_datetime }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.stopped_at %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Ended</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.stopped_at|local_datetime }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.duration_seconds %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Duration</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session.duration_seconds // 3600 }}h {{ (session.duration_seconds % 3600) // 60 }}m</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.device_model %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Device Model</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session.device_model }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session_meta.get('store_name') %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Store Name</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.store_name }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session_meta.get('serial_number') %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Serial #</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.serial_number }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Edit Session</h2>
|
||||||
|
<form id="edit-form" onsubmit="saveSession(event)">
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
|
||||||
|
<input type="text" id="edit-label" name="session_label"
|
||||||
|
value="{{ session.session_label or '' }}"
|
||||||
|
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
|
||||||
|
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Required Recording Window -->
|
||||||
|
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
|
||||||
|
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
|
||||||
|
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
|
||||||
|
<select id="edit-period-type" name="period_type"
|
||||||
|
onchange="fillPeriodDefaults()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
<option value="">— Not Set —</option>
|
||||||
|
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
|
||||||
|
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
|
||||||
|
<option value="weekend_day" {% if session.period_type == 'weekend_day' %}selected{% endif %}>Weekend Day</option>
|
||||||
|
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From (hour)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
|
||||||
|
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 19"
|
||||||
|
oninput="updateWindowPreview()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To (hour)</label>
|
||||||
|
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
|
||||||
|
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 7"
|
||||||
|
oninput="updateWindowPreview()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
|
||||||
|
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
|
||||||
|
{% set sh = session.period_start_hour %}
|
||||||
|
{% set eh = session.period_end_hour %}
|
||||||
|
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} → {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" id="edit-report-date" name="report_date"
|
||||||
|
value="{{ report_date or '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Device On/Off Times -->
|
||||||
|
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</label>
|
||||||
|
<input type="datetime-local" id="edit-started-at" name="started_at"
|
||||||
|
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered off</label>
|
||||||
|
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
|
||||||
|
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="save-status" class="hidden text-xs text-center pt-2"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Files + Report Actions -->
|
||||||
|
<div class="lg:col-span-2 space-y-5">
|
||||||
|
|
||||||
|
<!-- Files List -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||||
|
Data Files
|
||||||
|
<span class="ml-2 text-xs font-normal text-gray-400">({{ files|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{% if files %}
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for f in files %}
|
||||||
|
{% set fname = f.file_path.split('/')[-1] %}
|
||||||
|
{% set is_rnd = fname.lower().endswith('.rnd') %}
|
||||||
|
{% set is_leq = '_leq_' in fname.lower() or fname.lower().startswith('au2_') %}
|
||||||
|
<div class="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center
|
||||||
|
{% if is_rnd %}bg-green-100 dark:bg-green-900/30{% else %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
{% if is_rnd %}
|
||||||
|
<svg class="w-4 h-4 text-green-600 dark:text-green-400" 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>
|
||||||
|
{% else %}
|
||||||
|
<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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name + meta -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ fname }}</div>
|
||||||
|
<div class="text-xs text-gray-400 flex items-center gap-2 mt-0.5">
|
||||||
|
<span>{{ f.file_type | upper }}</span>
|
||||||
|
{% if f.file_size_bytes %}
|
||||||
|
<span>{{ (f.file_size_bytes / 1024) | round(1) }} KB</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_leq %}<span class="text-green-600 dark:text-green-400 font-medium">Leq</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if is_rnd %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/view-rnd"
|
||||||
|
class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{% if is_leq %}
|
||||||
|
<button onclick="openSingleFileReport('{{ f.id }}', '{{ fname }}')"
|
||||||
|
class="px-2 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 transition-colors">
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/download"
|
||||||
|
class="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-5 py-10 text-center text-gray-400">
|
||||||
|
<p>No files found for this session.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Actions -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Report Actions</h2>
|
||||||
|
|
||||||
|
{% if session.status == 'completed' %}
|
||||||
|
{% set has_rnd = files | selectattr('file_type', 'equalto', 'rnd') | list | length > 0 %}
|
||||||
|
{% if has_rnd %}
|
||||||
|
<div class="p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-2">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Use the <strong>Combined Report Wizard</strong> to generate an Excel report for this session, or click <strong>View</strong> on a Leq file above to access per-file reporting.
|
||||||
|
{% if session.period_start_hour is not none %}
|
||||||
|
<br><span class="text-indigo-600 dark:text-indigo-400">Period window {{ session.period_start_hour }}:00–{{ session.period_end_hour }}:00 will be applied.</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<a href="/projects/{{ project_id }}?tab=data"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||||
|
<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 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"></path>
|
||||||
|
</svg>
|
||||||
|
Go to Combined Report Wizard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">No .rnd files found — upload data to generate a report.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Reports are available after the session is completed.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
const SESSION_ID = '{{ session.id }}';
|
||||||
|
|
||||||
|
const PERIOD_DEFAULT_HOURS = {
|
||||||
|
weekday_day: {start: 7, end: 19},
|
||||||
|
weekday_night: {start: 19, end: 7},
|
||||||
|
weekend_day: {start: 7, end: 19},
|
||||||
|
weekend_night: {start: 19, end: 7},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fillPeriodDefaults() {
|
||||||
|
const pt = document.getElementById('edit-period-type').value;
|
||||||
|
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||||
|
if (defaults) {
|
||||||
|
document.getElementById('edit-start-hour').value = defaults.start;
|
||||||
|
document.getElementById('edit-end-hour').value = defaults.end;
|
||||||
|
}
|
||||||
|
updateWindowPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWindowPreview() {
|
||||||
|
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
|
||||||
|
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
|
||||||
|
const el = document.getElementById('window-preview');
|
||||||
|
if (!el) return;
|
||||||
|
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
|
||||||
|
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
|
||||||
|
const crosses = eh <= sh;
|
||||||
|
el.textContent = `Window: ${fmt(sh)} → ${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once on load to populate preview if values already set
|
||||||
|
document.addEventListener('DOMContentLoaded', updateWindowPreview);
|
||||||
|
|
||||||
|
async function saveSession(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById('save-status');
|
||||||
|
status.className = 'text-xs text-center pt-1 text-gray-400';
|
||||||
|
status.textContent = 'Saving…';
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
|
||||||
|
const form = document.getElementById('edit-form');
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
const label = form.session_label.value.trim();
|
||||||
|
payload.session_label = label || null;
|
||||||
|
|
||||||
|
const pt = form.period_type.value;
|
||||||
|
payload.period_type = pt || null;
|
||||||
|
|
||||||
|
const sh = form.period_start_hour.value;
|
||||||
|
const eh = form.period_end_hour.value;
|
||||||
|
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
|
||||||
|
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
|
||||||
|
|
||||||
|
const rd = form.report_date.value;
|
||||||
|
payload.report_date = rd || null;
|
||||||
|
|
||||||
|
const sa = form.started_at.value;
|
||||||
|
if (sa) payload.started_at = sa;
|
||||||
|
|
||||||
|
const st = form.stopped_at.value;
|
||||||
|
if (st) payload.stopped_at = st;
|
||||||
|
else if (form.stopped_at.value === '') payload.stopped_at = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
// Update displayed label
|
||||||
|
const newLabel = result.session_label || ('Session ' + SESSION_ID.slice(0, 8) + '…');
|
||||||
|
document.getElementById('header-label').textContent = newLabel;
|
||||||
|
document.getElementById('info-label').textContent = result.session_label || '—';
|
||||||
|
document.getElementById('info-period').textContent = {
|
||||||
|
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
|
||||||
|
}[result.period_type] || '—';
|
||||||
|
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
|
||||||
|
|
||||||
|
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
|
||||||
|
status.textContent = 'Saved!';
|
||||||
|
setTimeout(() => status.classList.add('hidden'), 2500);
|
||||||
|
} catch(err) {
|
||||||
|
status.className = 'text-xs text-center pt-1 text-red-500';
|
||||||
|
status.textContent = 'Error: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSingleFileReport(fileId, filename) {
|
||||||
|
window.location.href = `/api/projects/${PROJECT_ID}/files/${fileId}/view-rnd`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+160
-1
@@ -41,6 +41,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</button>
|
</button>
|
||||||
|
<button class="settings-tab text-gray-400 dark:text-gray-500" data-tab="developer" onclick="showTab('developer')">
|
||||||
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
Developer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
@@ -349,6 +355,25 @@
|
|||||||
</form>
|
</form>
|
||||||
<div id="uploadResult" class="hidden mt-3"></div>
|
<div id="uploadResult" class="hidden mt-3"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Deleted Projects -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Deleted Projects</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Projects that have been soft-deleted. Restore them or permanently remove them.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadDeletedProjects()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="deletedProjectsList">
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -514,6 +539,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Developer Tab -->
|
||||||
|
<div id="developer-tab" class="tab-content hidden">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-1">Developer Tools</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Admin-only tools for managing field watcher agents and diagnosing connectivity.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Watcher Manager -->
|
||||||
|
<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">Watcher Manager</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Monitor series3-watcher and thor-watcher agents. View status, log tails, and push remote updates.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/watchers"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- SFM Event DB Manager -->
|
||||||
|
<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 Event DB Manager</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Browse, flag, and <strong>delete</strong> events from SFM's events table across all units. Destructive — also cleans up on-disk waveform files. Use for cleaning bogus events from a misbehaving unit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/events"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
@@ -552,6 +649,67 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ========== DELETED PROJECTS ==========
|
||||||
|
|
||||||
|
async function loadDeletedProjects() {
|
||||||
|
const container = document.getElementById('deletedProjectsList');
|
||||||
|
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/deleted');
|
||||||
|
const projects = await resp.json();
|
||||||
|
if (!projects.length) {
|
||||||
|
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">No deleted projects.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
${projects.map(p => `
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900/30">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">${p.name}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
${p.client_name ? p.client_name + ' · ' : ''}Deleted ${new Date(p.deleted_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 ml-4">
|
||||||
|
<button onclick="restoreProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
||||||
|
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button onclick="permanentlyDeleteProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
||||||
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Delete Permanently
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<p class="text-sm text-red-500">Failed to load deleted projects.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreProject(projectId, name) {
|
||||||
|
if (!confirm(`Restore "${name}"?`)) return;
|
||||||
|
const resp = await fetch(`/api/projects/${projectId}/restore`, { method: 'POST' });
|
||||||
|
if (resp.ok) {
|
||||||
|
loadDeletedProjects();
|
||||||
|
} else {
|
||||||
|
const d = await resp.json();
|
||||||
|
alert('Failed to restore: ' + (d.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function permanentlyDeleteProject(projectId, name) {
|
||||||
|
if (!confirm(`Permanently delete "${name}" and all its data? This cannot be undone.`)) return;
|
||||||
|
const resp = await fetch(`/api/projects/${projectId}/permanent`, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
loadDeletedProjects();
|
||||||
|
} else {
|
||||||
|
const d = await resp.json();
|
||||||
|
alert('Failed to delete: ' + (d.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== TAB MANAGEMENT ==========
|
// ========== TAB MANAGEMENT ==========
|
||||||
|
|
||||||
function showTab(tabName) {
|
function showTab(tabName) {
|
||||||
@@ -577,9 +735,10 @@ function showTab(tabName) {
|
|||||||
// Save last active tab to localStorage
|
// Save last active tab to localStorage
|
||||||
localStorage.setItem('settings-last-tab', tabName);
|
localStorage.setItem('settings-last-tab', tabName);
|
||||||
|
|
||||||
// Load roster table when data tab is shown
|
// Load roster table and deleted projects when data tab is shown
|
||||||
if (tabName === 'data') {
|
if (tabName === 'data') {
|
||||||
loadRosterTable();
|
loadRosterTable();
|
||||||
|
loadDeletedProjects();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SFM Event Data - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
Checking SFM…
|
||||||
|
</span>
|
||||||
|
<button onclick="checkSFMHealth()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats bar -->
|
||||||
|
<div id="sfm-stats" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Known Units</span>
|
||||||
|
<span id="stat-units" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||||
|
<span id="stat-events" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab navigation -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex overflow-x-auto" aria-label="Tabs">
|
||||||
|
<button onclick="switchTab('events')" id="tab-events"
|
||||||
|
class="sfm-tab active-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
|
||||||
|
Events
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('units')" id="tab-units"
|
||||||
|
class="sfm-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
|
||||||
|
Units
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Events Tab ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="panel-events" class="sfm-panel p-6">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Unit Serial</label>
|
||||||
|
<select id="ev-serial" onchange="loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">All Units</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||||
|
<input type="datetime-local" id="ev-from" onchange="loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||||
|
<input type="datetime-local" id="ev-to" onchange="loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
|
||||||
|
<select id="ev-false-trigger" onchange="loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">All Events</option>
|
||||||
|
<option value="false">Real Events Only</option>
|
||||||
|
<option value="true">False Triggers Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
|
||||||
|
<select id="ev-limit" onchange="loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="250">250</option>
|
||||||
|
<option value="500" selected>500</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="clearEventFilters()"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
<button onclick="loadEvents()"
|
||||||
|
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-orange-600">
|
||||||
|
↻ Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events table -->
|
||||||
|
<div id="events-container" class="overflow-x-auto">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
|
||||||
|
Loading events…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Units Tab ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="panel-units" class="sfm-panel hidden p-6">
|
||||||
|
<div id="units-container">
|
||||||
|
<div class="text-center py-12 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 units…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end tab container -->
|
||||||
|
|
||||||
|
{# 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 {
|
||||||
|
color: #6b7280;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.sfm-tab:hover {
|
||||||
|
color: #374151;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
.dark .sfm-tab {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.dark .sfm-tab:hover {
|
||||||
|
color: #f3f4f6;
|
||||||
|
border-color: #4b5563;
|
||||||
|
}
|
||||||
|
.sfm-tab.active-tab {
|
||||||
|
color: #f48b1c;
|
||||||
|
border-color: #f48b1c;
|
||||||
|
}
|
||||||
|
.dark .sfm-tab.active-tab {
|
||||||
|
color: #f48b1c;
|
||||||
|
border-color: #f48b1c;
|
||||||
|
}
|
||||||
|
.sfm-panel { display: block; }
|
||||||
|
.sfm-panel.hidden { display: none; }
|
||||||
|
|
||||||
|
/* PPV colour thresholds */
|
||||||
|
.ppv-low { color: #10b981; }
|
||||||
|
.ppv-mid { color: #f59e0b; }
|
||||||
|
.ppv-high { color: #ef4444; font-weight: 600; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── State ───────────────────────────────────────────────────────────────────
|
||||||
|
let _knownSerials = [];
|
||||||
|
|
||||||
|
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('.sfm-tab').forEach(t => t.classList.remove('active-tab'));
|
||||||
|
document.querySelectorAll('.sfm-panel').forEach(p => p.classList.add('hidden'));
|
||||||
|
document.getElementById('tab-' + name).classList.add('active-tab');
|
||||||
|
document.getElementById('panel-' + name).classList.remove('hidden');
|
||||||
|
|
||||||
|
// Lazy-load tabs on first visit
|
||||||
|
if (name === 'units' && document.getElementById('units-container').innerHTML.includes('Loading')) loadUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SFM health ───────────────────────────────────────────────────────────────
|
||||||
|
async function checkSFMHealth() {
|
||||||
|
const badge = document.getElementById('sfm-status-badge');
|
||||||
|
badge.textContent = 'Checking…';
|
||||||
|
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sfm/health');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.sfm_status === 'connected') {
|
||||||
|
badge.textContent = '● SFM Connected';
|
||||||
|
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
badge.textContent = '● SFM Offline';
|
||||||
|
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
badge.textContent = '● SFM Error';
|
||||||
|
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sfm/db/units');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const units = await r.json();
|
||||||
|
_knownSerials = units.map(u => u.serial);
|
||||||
|
|
||||||
|
const totalEvents = units.reduce((s, u) => s + (u.total_events || 0), 0);
|
||||||
|
|
||||||
|
document.getElementById('stat-units').textContent = units.length;
|
||||||
|
document.getElementById('stat-events').textContent = totalEvents.toLocaleString();
|
||||||
|
|
||||||
|
// Populate serial dropdowns
|
||||||
|
['ev-serial'].forEach(id => {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
const cur = sel.value;
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
_knownSerials.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s; opt.textContent = s;
|
||||||
|
sel.add(opt);
|
||||||
|
});
|
||||||
|
if (cur) sel.value = cur;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load stats:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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>';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const serial = document.getElementById('ev-serial').value;
|
||||||
|
const from = document.getElementById('ev-from').value;
|
||||||
|
const to = document.getElementById('ev-to').value;
|
||||||
|
const ft = document.getElementById('ev-false-trigger').value;
|
||||||
|
const limit = document.getElementById('ev-limit').value;
|
||||||
|
|
||||||
|
if (serial) params.set('serial', serial);
|
||||||
|
if (from) params.set('from_dt', from.replace('T', ' '));
|
||||||
|
if (to) params.set('to_dt', to.replace('T', ' '));
|
||||||
|
if (ft) params.set('false_trigger', ft);
|
||||||
|
params.set('limit', limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sfm/db/events?' + params.toString());
|
||||||
|
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
||||||
|
const d = await r.json();
|
||||||
|
_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 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);
|
||||||
|
const lng = fmtPPV(ev.long_ppv);
|
||||||
|
const pvs = fmtPPV(ev.peak_vector_sum);
|
||||||
|
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
|
||||||
|
const ft = ev.false_trigger ? `<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 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('${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>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.tran_ppv)}">${tran}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.vert_ppv)}">${vert}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm">
|
||||||
|
${ft}
|
||||||
|
<button onclick="event.stopPropagation(); toggleFalseTrigger('${ev.id}', ${ev.false_trigger ? 'false' : 'true'}, this)"
|
||||||
|
class="ml-1 px-2 py-0.5 rounded text-xs ${ev.false_trigger ? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 hover:bg-yellow-100' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-yellow-50'}"
|
||||||
|
title="${ev.false_trigger ? 'Clear false trigger' : 'Mark as false trigger'}">
|
||||||
|
${ev.false_trigger ? '✕ FT' : 'Flag FT'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">Showing ${events.length} of ${total} events</div>
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
${_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>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPPV(v) {
|
||||||
|
if (v == null) return '—';
|
||||||
|
return v.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppvClass(v) {
|
||||||
|
if (v == null) return 'text-gray-400';
|
||||||
|
if (v < 0.5) return 'ppv-low';
|
||||||
|
if (v < 2.0) return 'ppv-mid';
|
||||||
|
return 'ppv-high';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEventFilters() {
|
||||||
|
document.getElementById('ev-serial').value = '';
|
||||||
|
document.getElementById('ev-from').value = '';
|
||||||
|
document.getElementById('ev-to').value = '';
|
||||||
|
document.getElementById('ev-false-trigger').value = '';
|
||||||
|
document.getElementById('ev-limit').value = '500';
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFalseTrigger(id, newValue, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/sfm/db/events/${id}/false_trigger?value=${newValue}`, { method: 'PATCH' });
|
||||||
|
if (r.ok) {
|
||||||
|
// Refresh the table after short delay
|
||||||
|
setTimeout(loadEvents, 300);
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
const container = document.getElementById('units-container');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sfm/db/units');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const units = await r.json();
|
||||||
|
|
||||||
|
if (units.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-12 text-gray-500 text-sm">No units in database. Waiting for series3-watcher to forward events from Blastware ACH.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = units.map(u => {
|
||||||
|
const lastSeen = u.last_seen ? u.last_seen.slice(0, 19).replace('T', ' ') : '—';
|
||||||
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||||
|
<td class="px-4 py-3 text-sm font-mono font-semibold text-seismo-orange">${esc(u.serial)}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${lastSeen}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${(u.total_events || 0).toLocaleString()}</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<button onclick="filterEventsBySerial('${esc(u.serial)}')"
|
||||||
|
class="px-3 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
View Events
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<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 text-left">Serial</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Last Seen</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Events</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Failed to load units: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterEventsBySerial(serial) {
|
||||||
|
document.getElementById('ev-serial').value = serial;
|
||||||
|
switchTab('events');
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
checkSFMHealth();
|
||||||
|
loadEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "partials/fleet_tab_strip.html" %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
<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">
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
{% 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">
|
||||||
|
|
||||||
|
<!-- Field Deploy (mobile-first) -->
|
||||||
|
<a href="/deploy"
|
||||||
|
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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">Field Deploy</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
On site? Pick a unit, snap an install photo, leave. GPS is auto-captured. Classify the project/location later from a desk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Pending Deployments (the hopper) -->
|
||||||
|
<a href="/tools/pending-deployments"
|
||||||
|
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-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">Pending Deployments</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Field captures waiting to be classified into a project + location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Deployment History -->
|
||||||
|
<a href="/tools/deployment-history"
|
||||||
|
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-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-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 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">Deployment History</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
12-month calendar of every unit assignment across every project. Visual bars per project per day; click a day for the full active-deployments list.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Unit Swap -->
|
||||||
|
<a href="/tools/unit-swap"
|
||||||
|
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-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-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="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">Unit Swap</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Field-swap a unit (and modem) at a vibration location. Pick project → location → incoming unit → confirm. Optional photo of the new install.
|
||||||
|
</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 %}
|
||||||
+1599
-25
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@
|
|||||||
{{ location.name }}
|
{{ location.name }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Monitoring Location • {{ project.name }}
|
Monitoring Location • {{ project.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -65,6 +65,11 @@
|
|||||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||||
Overview
|
Overview
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('events')"
|
||||||
|
data-tab="events"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
|
Events
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('settings')"
|
<button onclick="switchTab('settings')"
|
||||||
data-tab="settings"
|
data-tab="settings"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
@@ -116,20 +121,36 @@
|
|||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<!-- Seismograph row -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</div>
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
{{ assigned_unit.id }}
|
{{ assigned_unit.id }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if assigned_unit.unit_type %}
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if assigned_unit.device_type %}
|
<!-- Modem row -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
|
||||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
{% if assigned_modem %}
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ assigned_modem.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} • {% endif %}{{ assigned_modem.ip_address or '' }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% if assignment %}
|
{% if assignment %}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||||
@@ -142,10 +163,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="pt-2">
|
<div class="pt-2 flex gap-2 flex-wrap">
|
||||||
|
<button onclick="openSwapModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
|
||||||
|
Swap Unit / Modem
|
||||||
|
</button>
|
||||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors text-sm">
|
||||||
Unassign Unit
|
Unassign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +180,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||||
<button onclick="openAssignModal()"
|
<button onclick="openSwapModal()"
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
Assign a Unit
|
Assign a Unit
|
||||||
</button>
|
</button>
|
||||||
@@ -165,6 +190,152 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Tab -->
|
||||||
|
<div id="events-tab" class="tab-panel hidden">
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div id="events-stats" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||||
|
<span id="ev-stat-count" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">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>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||||
|
<span id="ev-stat-last" class="text-lg font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
|
||||||
|
<span id="ev-stat-ft" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignments used (transparency: which seismographs contributed events) -->
|
||||||
|
<div id="events-assignments-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 hidden">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Seismographs deployed at this location</h3>
|
||||||
|
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||||
|
<span class="inline-block w-4 text-center">✎</span>
|
||||||
|
Click the pencil to backdate a deployment so historical events get attributed to this location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit-assignment modal -->
|
||||||
|
<div id="assignment-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Edit Deployment Window</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<span id="ae-unit-label" class="font-mono text-seismo-orange">—</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeAssignmentEditModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="assignment-edit-form" class="p-6 space-y-4">
|
||||||
|
<input type="hidden" id="ae-assignment-id">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned From</label>
|
||||||
|
<input type="datetime-local" id="ae-assigned-at" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Assigned Until
|
||||||
|
<span class="text-xs text-gray-500 ml-1">(leave blank if still active)</span>
|
||||||
|
</label>
|
||||||
|
<input type="datetime-local" id="ae-assigned-until"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea id="ae-notes" rows="2" placeholder="Optional — e.g. 'backdated to reflect physical install date'"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ae-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeAssignmentEditModal()"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="ae-submit-btn"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||||
|
<input type="datetime-local" id="ev-filter-from" onchange="loadLocationEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||||
|
<input type="datetime-local" id="ev-filter-to" onchange="loadLocationEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
|
||||||
|
<select id="ev-filter-ft" onchange="loadLocationEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">All Events</option>
|
||||||
|
<option value="false">Real Events Only</option>
|
||||||
|
<option value="true">False Triggers Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
|
||||||
|
<select id="ev-filter-limit" onchange="loadLocationEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="250">250</option>
|
||||||
|
<option value="500" selected>500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="clearEventFilters()"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button onclick="loadLocationEvents()"
|
||||||
|
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy">
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event table -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<div id="events-container" class="overflow-x-auto">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
|
||||||
|
Loading events…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
<div id="settings-tab" class="tab-panel hidden">
|
<div id="settings-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
@@ -214,47 +385,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assign Unit Modal -->
|
<!-- Assign / Swap Modal -->
|
||||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="swap-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
<h2 id="swap-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a seismograph and optionally a modem for this location</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<button onclick="closeSwapModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="assign-form" class="p-6 space-y-4">
|
<form id="swap-form" class="p-6 space-y-5">
|
||||||
|
<!-- Seismograph picker -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<select id="assign-unit-id" name="unit_id"
|
Seismograph <span class="text-red-500">*</span>
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
</label>
|
||||||
<option value="">Loading units...</option>
|
<input id="swap-unit-search" type="text" placeholder="Search by ID or model..."
|
||||||
</select>
|
oninput="filterSwapList('unit')"
|
||||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<div id="swap-unit-list"
|
||||||
|
class="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
|
||||||
|
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="swap-unit-id" name="unit_id" required>
|
||||||
|
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem picker -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Modem <span class="text-xs text-gray-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input id="swap-modem-search" type="text" placeholder="Search by ID, model, or IP..."
|
||||||
|
oninput="filterSwapList('modem')"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<div id="swap-modem-list"
|
||||||
|
class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
|
||||||
|
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="swap-modem-id" name="modem_id">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
<textarea id="assign-notes" name="notes" rows="2"
|
<textarea id="swap-notes" name="notes" rows="2"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
<div id="swap-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button type="button" onclick="closeAssignModal()"
|
<button type="button" onclick="closeSwapModal()"
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit"
|
<button type="submit" id="swap-submit-btn"
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
Assign Unit
|
Assign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -264,6 +457,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
const locationId = "{{ location_id }}";
|
const locationId = "{{ location_id }}";
|
||||||
|
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
@@ -281,6 +475,293 @@ function switchTab(tabName) {
|
|||||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||||
}
|
}
|
||||||
|
// Lazy-load Events tab on first visit (or whenever it's reopened).
|
||||||
|
if (tabName === 'events' && !_eventsLoaded) {
|
||||||
|
loadLocationEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events tab ───────────────────────────────────────────────────────────────
|
||||||
|
let _eventsLoaded = false;
|
||||||
|
|
||||||
|
function clearEventFilters() {
|
||||||
|
document.getElementById('ev-filter-from').value = '';
|
||||||
|
document.getElementById('ev-filter-to').value = '';
|
||||||
|
document.getElementById('ev-filter-ft').value = '';
|
||||||
|
document.getElementById('ev-filter-limit').value = '500';
|
||||||
|
loadLocationEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocationEvents() {
|
||||||
|
const container = document.getElementById('events-container');
|
||||||
|
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const from = document.getElementById('ev-filter-from').value;
|
||||||
|
const to = document.getElementById('ev-filter-to').value;
|
||||||
|
const ft = document.getElementById('ev-filter-ft').value;
|
||||||
|
const limit = document.getElementById('ev-filter-limit').value;
|
||||||
|
if (from) params.set('from_dt', from.replace('T', ' '));
|
||||||
|
if (to) params.set('to_dt', to.replace('T', ' '));
|
||||||
|
if (ft) params.set('false_trigger', ft);
|
||||||
|
params.set('limit', limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations/${locationId}/events?${params.toString()}`);
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
const d = await r.json();
|
||||||
|
_eventsLoaded = true;
|
||||||
|
renderEventStats(d.stats);
|
||||||
|
renderAssignmentsUsed(d.assignments_used);
|
||||||
|
renderEventTable(d.events, d.count, container);
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEventStats(stats) {
|
||||||
|
const s = stats || {};
|
||||||
|
document.getElementById('ev-stat-count').textContent = (s.event_count ?? 0).toLocaleString();
|
||||||
|
document.getElementById('ev-stat-ft').textContent = (s.false_trigger_count ?? 0).toLocaleString();
|
||||||
|
|
||||||
|
if (s.peak_pvs != null) {
|
||||||
|
document.getElementById('ev-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
|
||||||
|
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
|
||||||
|
const who = s.peak_pvs_serial || '';
|
||||||
|
document.getElementById('ev-stat-peak-when').textContent = [when, who].filter(Boolean).join(' · ') || '—';
|
||||||
|
} else {
|
||||||
|
document.getElementById('ev-stat-peak').textContent = '—';
|
||||||
|
document.getElementById('ev-stat-peak-when').textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.last_event) {
|
||||||
|
const dt = s.last_event.slice(0, 19).replace('T', ' ');
|
||||||
|
document.getElementById('ev-stat-last').textContent = dt;
|
||||||
|
} else {
|
||||||
|
document.getElementById('ev-stat-last').textContent = '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssignmentsUsed(assignments) {
|
||||||
|
const card = document.getElementById('events-assignments-card');
|
||||||
|
const listEl = document.getElementById('ev-assignments-list');
|
||||||
|
const countEl = document.getElementById('ev-assignments-count');
|
||||||
|
|
||||||
|
if (!assignments || assignments.length === 0) {
|
||||||
|
card.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.classList.remove('hidden');
|
||||||
|
countEl.textContent = `${assignments.length} assignment${assignments.length === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
listEl.innerHTML = assignments.map(a => {
|
||||||
|
const start = a.assigned_at ? a.assigned_at.slice(0, 10) : '?';
|
||||||
|
const end = a.assigned_until ? a.assigned_until.slice(0, 10) : 'present';
|
||||||
|
const isActive = !a.assigned_until;
|
||||||
|
const badge = isActive
|
||||||
|
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||||
|
: '';
|
||||||
|
const editAttr = encodeURIComponent(JSON.stringify({
|
||||||
|
id: a.assignment_id,
|
||||||
|
unit_id: a.unit_id,
|
||||||
|
assigned_at: a.assigned_at,
|
||||||
|
assigned_until: a.assigned_until,
|
||||||
|
}));
|
||||||
|
return `<div class="py-2 flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
|
||||||
|
${badge}
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">${start} → ${end}</span>
|
||||||
|
<button type="button"
|
||||||
|
onclick="openAssignmentEditModal('${editAttr}')"
|
||||||
|
title="Edit deployment dates"
|
||||||
|
class="text-gray-400 hover:text-seismo-orange transition-colors p-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="deleteAssignment('${esc(a.assignment_id)}', '${esc(a.unit_id)}', '${start} → ${end}')"
|
||||||
|
title="Delete this assignment record (for mis-clicks / duplicates)"
|
||||||
|
class="text-gray-400 hover:text-red-600 transition-colors p-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assignment-edit modal ───────────────────────────────────────────────────
|
||||||
|
function _isoToInputValue(iso) {
|
||||||
|
// Convert "2026-04-14T02:19:27" (or "2026-04-14 02:19:27") to "2026-04-14T02:19" for datetime-local input.
|
||||||
|
if (!iso) return '';
|
||||||
|
const cleaned = iso.replace(' ', 'T');
|
||||||
|
return cleaned.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssignmentEditModal(encodedJson) {
|
||||||
|
const data = JSON.parse(decodeURIComponent(encodedJson));
|
||||||
|
document.getElementById('ae-assignment-id').value = data.id;
|
||||||
|
document.getElementById('ae-unit-label').textContent = data.unit_id;
|
||||||
|
document.getElementById('ae-assigned-at').value = _isoToInputValue(data.assigned_at);
|
||||||
|
document.getElementById('ae-assigned-until').value = _isoToInputValue(data.assigned_until);
|
||||||
|
document.getElementById('ae-notes').value = '';
|
||||||
|
document.getElementById('ae-error').classList.add('hidden');
|
||||||
|
document.getElementById('assignment-edit-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAssignment(assignmentId, unitId, windowLabel) {
|
||||||
|
// For mis-clicks / accidental duplicate assignments. Backend refuses
|
||||||
|
// if there's a real recording session inside the window — those should
|
||||||
|
// go through Edit or Unassign instead.
|
||||||
|
const msg = `Delete this assignment?\n\n`
|
||||||
|
+ `Unit: ${unitId}\n`
|
||||||
|
+ `Window: ${windowLabel}\n\n`
|
||||||
|
+ `This is for assignments created in error. Events that fell `
|
||||||
|
+ `in this window will become unattributed. The unit's deployment `
|
||||||
|
+ `history will log the deletion for audit.\n\n`
|
||||||
|
+ `If the unit actually was deployed here, use Edit or Unassign instead.`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
await loadLocationEvents(); // Refresh stats + table without this assignment.
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to delete assignment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignmentEditModal() {
|
||||||
|
document.getElementById('assignment-edit-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('assignment-edit-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const errEl = document.getElementById('ae-error');
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
|
||||||
|
const assignmentId = document.getElementById('ae-assignment-id').value;
|
||||||
|
const assignedAt = document.getElementById('ae-assigned-at').value;
|
||||||
|
const assignedUntil = document.getElementById('ae-assigned-until').value;
|
||||||
|
const notes = document.getElementById('ae-notes').value.trim();
|
||||||
|
|
||||||
|
if (!assignedAt) {
|
||||||
|
errEl.textContent = 'Assigned From is required.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { assigned_at: assignedAt };
|
||||||
|
payload.assigned_until = assignedUntil || null;
|
||||||
|
if (notes) payload.notes = notes;
|
||||||
|
|
||||||
|
const btn = document.getElementById('ae-submit-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Saving…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
closeAssignmentEditModal();
|
||||||
|
await loadLocationEvents(); // Refresh stats + table with new window.
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message || 'Failed to update assignment.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Save';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('assignment-edit-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeAssignmentEditModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderEventTable(events, total, container) {
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');
|
||||||
|
const msg = haveAssignments
|
||||||
|
? 'No events recorded for the assignments above within the current filter.'
|
||||||
|
: 'No seismographs have been assigned to this location yet. Assign one to start collecting events.';
|
||||||
|
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = events.map(ev => {
|
||||||
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||||
|
const tran = fmtPPV(ev.tran_ppv);
|
||||||
|
const vert = fmtPPV(ev.vert_ppv);
|
||||||
|
const lng = fmtPPV(ev.long_ppv);
|
||||||
|
const pvs = fmtPPV(ev.peak_vector_sum);
|
||||||
|
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
|
||||||
|
const ft = ev.false_trigger
|
||||||
|
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 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" 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>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm">${ft}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3">Showing ${events.length} of ${total.toLocaleString()} events</div>
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPPV(v) {
|
||||||
|
if (v == null) return '—';
|
||||||
|
return v.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppvClass(v) {
|
||||||
|
if (v == null) return 'text-gray-400';
|
||||||
|
if (v < 0.5) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
||||||
|
return 'text-red-600 dark:text-red-400 font-semibold';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location settings form submission
|
// Location settings form submission
|
||||||
@@ -314,60 +795,156 @@ document.getElementById('location-settings-form').addEventListener('submit', asy
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign modal
|
// Swap / Assign modal
|
||||||
function openAssignModal() {
|
async function openSwapModal() {
|
||||||
document.getElementById('assign-modal').classList.remove('hidden');
|
document.getElementById('swap-modal').classList.remove('hidden');
|
||||||
loadAvailableUnits();
|
document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
|
||||||
|
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
|
||||||
|
document.getElementById('swap-error').classList.add('hidden');
|
||||||
|
document.getElementById('swap-notes').value = '';
|
||||||
|
document.getElementById('swap-unit-search').value = '';
|
||||||
|
document.getElementById('swap-modem-search').value = '';
|
||||||
|
document.getElementById('swap-unit-id').value = '';
|
||||||
|
document.getElementById('swap-modem-id').value = '';
|
||||||
|
await Promise.all([loadSwapUnits(), loadSwapModems()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAssignModal() {
|
function closeSwapModal() {
|
||||||
document.getElementById('assign-modal').classList.add('hidden');
|
document.getElementById('swap-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAvailableUnits() {
|
let _swapUnits = [];
|
||||||
try {
|
let _swapModems = [];
|
||||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load available units');
|
|
||||||
const data = await response.json();
|
|
||||||
const select = document.getElementById('assign-unit-id');
|
|
||||||
select.innerHTML = '<option value="">Select a unit</option>';
|
|
||||||
|
|
||||||
if (!data.length) {
|
function _fuzzyMatch(query, text) {
|
||||||
document.getElementById('assign-empty').classList.remove('hidden');
|
if (!query) return true;
|
||||||
return;
|
const q = query.toLowerCase();
|
||||||
}
|
const t = text.toLowerCase();
|
||||||
|
// Substring match first (fast), then character-sequence fuzzy
|
||||||
|
if (t.includes(q)) return true;
|
||||||
|
let qi = 0;
|
||||||
|
for (let i = 0; i < t.length && qi < q.length; i++) {
|
||||||
|
if (t[i] === q[qi]) qi++;
|
||||||
|
}
|
||||||
|
return qi === q.length;
|
||||||
|
}
|
||||||
|
|
||||||
data.forEach(unit => {
|
function _renderSwapList(type, items, selectedId, noSelectionLabel) {
|
||||||
const option = document.createElement('option');
|
const listEl = document.getElementById(`swap-${type}-list`);
|
||||||
option.value = unit.id;
|
if (!items.length) {
|
||||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
listEl.innerHTML = `<div class="px-3 py-4 text-center text-sm text-gray-400">No results</div>`;
|
||||||
select.appendChild(option);
|
return;
|
||||||
});
|
}
|
||||||
} catch (err) {
|
listEl.innerHTML = items.map(item => {
|
||||||
const errorEl = document.getElementById('assign-error');
|
const isSelected = item.value === selectedId;
|
||||||
errorEl.textContent = err.message || 'Failed to load units.';
|
return `<button type="button"
|
||||||
errorEl.classList.remove('hidden');
|
onclick="selectSwapItem('${type}', '${item.value}', this)"
|
||||||
|
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${isSelected ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white text-sm">${item.primary}</span>
|
||||||
|
${item.secondary ? `<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">${item.secondary}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${isSelected ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSwapItem(type, value, btn) {
|
||||||
|
document.getElementById(`swap-${type}-id`).value = value;
|
||||||
|
// Update visual state
|
||||||
|
const list = document.getElementById(`swap-${type}-list`);
|
||||||
|
list.querySelectorAll('button').forEach(b => {
|
||||||
|
b.classList.remove('bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
b.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-gray-400 dark:border-gray-500';
|
||||||
|
});
|
||||||
|
btn.classList.add('bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
btn.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-seismo-orange bg-seismo-orange';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSwapList(type) {
|
||||||
|
const query = document.getElementById(`swap-${type}-search`).value;
|
||||||
|
const items = type === 'unit' ? _swapUnits : _swapModems;
|
||||||
|
const selectedId = document.getElementById(`swap-${type}-id`).value;
|
||||||
|
const filtered = items.filter(item =>
|
||||||
|
_fuzzyMatch(query, item.primary + ' ' + (item.secondary || '') + ' ' + (item.searchText || ''))
|
||||||
|
);
|
||||||
|
_renderSwapList(type, filtered, selectedId, type === 'modem' ? 'No modem' : null);
|
||||||
|
// Re-add "No modem" option for modems
|
||||||
|
if (type === 'modem') {
|
||||||
|
const listEl = document.getElementById('swap-modem-list');
|
||||||
|
const noModemBtn = `<button type="button"
|
||||||
|
onclick="selectSwapItem('modem', '', this)"
|
||||||
|
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${!selectedId ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 italic">No modem</span>
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${!selectedId ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
|
||||||
|
</button>`;
|
||||||
|
listEl.insertAdjacentHTML('afterbegin', noModemBtn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
async function loadSwapUnits() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load units');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
document.getElementById('swap-units-empty').classList.remove('hidden');
|
||||||
|
document.getElementById('swap-unit-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">No available seismographs.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('swap-units-empty').classList.add('hidden');
|
||||||
|
_swapUnits = data.map(u => ({
|
||||||
|
value: u.id,
|
||||||
|
primary: u.id,
|
||||||
|
secondary: [u.model, u.location].filter(Boolean).join(' — '),
|
||||||
|
searchText: u.model + ' ' + u.location,
|
||||||
|
}));
|
||||||
|
_renderSwapList('unit', _swapUnits, document.getElementById('swap-unit-id').value);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
|
||||||
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSwapModems() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-modems`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load modems');
|
||||||
|
const data = await response.json();
|
||||||
|
_swapModems = data.map(m => ({
|
||||||
|
value: m.id,
|
||||||
|
primary: m.id,
|
||||||
|
secondary: [m.hardware_model, m.ip_address].filter(Boolean).join(' — '),
|
||||||
|
searchText: (m.hardware_model || '') + ' ' + (m.ip_address || ''),
|
||||||
|
}));
|
||||||
|
filterSwapList('modem'); // renders with "No modem" prepended
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load modems:', err);
|
||||||
|
document.getElementById('swap-modem-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">Could not load modems.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('swap-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const unitId = document.getElementById('assign-unit-id').value;
|
const unitId = document.getElementById('swap-unit-id').value;
|
||||||
const notes = document.getElementById('assign-notes').value.trim();
|
const modemId = document.getElementById('swap-modem-id').value;
|
||||||
|
const notes = document.getElementById('swap-notes').value.trim();
|
||||||
|
|
||||||
if (!unitId) {
|
if (!unitId) {
|
||||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
document.getElementById('swap-error').textContent = 'Please select a seismograph.';
|
||||||
document.getElementById('assign-error').classList.remove('hidden');
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('unit_id', unitId);
|
formData.append('unit_id', unitId);
|
||||||
formData.append('notes', notes);
|
if (modemId) formData.append('modem_id', modemId);
|
||||||
|
if (notes) formData.append('notes', notes);
|
||||||
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
@@ -379,9 +956,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
|
|||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorEl = document.getElementById('assign-error');
|
document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
|
||||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,11 +981,15 @@ async function unassignUnit(assignmentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') closeAssignModal();
|
if (e.key === 'Escape') closeSwapModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
||||||
if (e.target === this) closeAssignModal();
|
if (e.target === this) closeSwapModal();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
||||||
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
|
<script src="/static/event-modal.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user