Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ed00bf70e | |||
| 43c804d0c4 | |||
| c1f995b4d3 | |||
| 2905a327be | |||
| 4b2bb9a9c9 | |||
| 1d9fd00cc2 | |||
| db8d666aa1 | |||
| b2bfa6d268 | |||
| 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 | |||
| 32d2a57bc9 |
+239
@@ -5,6 +5,245 @@ All notable changes to Terra-View will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.13.1] - 2026-05-29
|
||||
|
||||
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
|
||||
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
|
||||
|
||||
### Migration Notes
|
||||
|
||||
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/terra-view
|
||||
docker compose build terra-view && docker compose up -d terra-view
|
||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||
```
|
||||
|
||||
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
|
||||
|
||||
---
|
||||
|
||||
## [0.13.0] - 2026-05-29
|
||||
|
||||
The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
|
||||
|
||||
### Added — Event-detail modal: Chart.js waveform/histogram panels
|
||||
|
||||
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
|
||||
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
|
||||
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
|
||||
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
|
||||
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
|
||||
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
|
||||
|
||||
### Added — Event-detail modal: PDF preview + downloads + review form
|
||||
|
||||
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
|
||||
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
|
||||
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
|
||||
|
||||
### Added — `/admin/events` row click opens the modal
|
||||
|
||||
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
|
||||
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
|
||||
|
||||
### Added — `mic_unit_pref` user setting (Settings → General)
|
||||
|
||||
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
|
||||
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
|
||||
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
|
||||
|
||||
### Fixed — Docker Compose: SFM container can finally read the DB
|
||||
|
||||
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
|
||||
|
||||
### Migration Notes
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/terra-view
|
||||
# Apply the new column to the database — required. Idempotent.
|
||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||
|
||||
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
|
||||
docker compose build terra-view && docker compose up -d
|
||||
```
|
||||
|
||||
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
|
||||
|
||||
### What's NOT in this release
|
||||
|
||||
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Terra-View v0.10.0
|
||||
# Terra-View v0.13.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.
|
||||
|
||||
## 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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **Database Management**: Comprehensive backup and restore system
|
||||
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||
@@ -496,6 +501,19 @@ docker compose down -v
|
||||
|
||||
## 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.
|
||||
@@ -612,9 +630,11 @@ MIT
|
||||
|
||||
## Version
|
||||
|
||||
**Current: 0.10.0** — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
||||
**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
|
||||
|
||||
Previous: 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
||||
Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
||||
|
||||
0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
||||
|
||||
0.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
|
||||
|
||||
|
||||
+31
-1
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.10.0"
|
||||
VERSION = "0.13.1"
|
||||
if ENVIRONMENT == "development":
|
||||
_build = os.getenv("BUILD_NUMBER", "0")
|
||||
if _build and _build != "0":
|
||||
@@ -109,6 +109,12 @@ 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
|
||||
app.include_router(projects.router)
|
||||
app.include_router(project_locations.router)
|
||||
@@ -269,6 +275,30 @@ async def tools_page(request: Request):
|
||||
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)
|
||||
async def modems_page(request: Request):
|
||||
"""Field modems management dashboard"""
|
||||
|
||||
@@ -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,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,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration: Add mic_unit_pref column to user_preferences.
|
||||
|
||||
Adds a single field controlling the mic channel's unit on the event-
|
||||
report waveform chart in the SFM event detail modal. "psi" (default —
|
||||
matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles
|
||||
elsewhere are always dBL regardless.
|
||||
|
||||
History: v0.13.0 originally shipped this with default "dBL", which
|
||||
made the website chart inconsistent with the PDF. v0.13.1 flips the
|
||||
default to "psi" so they match. This migration is idempotent and
|
||||
covers three cases:
|
||||
|
||||
1. Fresh DB without the column — adds it with default 'psi'.
|
||||
2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to
|
||||
'psi' on the assumption no operator deliberately picked 'dBL' yet.
|
||||
3. DB upgraded from later — flip step is a no-op for non-'dBL' values.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def migrate():
|
||||
possible_paths = [
|
||||
Path("data/seismo_fleet.db"),
|
||||
Path("data/sfm.db"),
|
||||
Path("data/seismo.db"),
|
||||
]
|
||||
db_path = next((p for p in possible_paths if p.exists()), None)
|
||||
if db_path is None:
|
||||
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||
print("Will be created with the new column when models.py initialises.")
|
||||
return
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
conn = sqlite3.connect(db_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("PRAGMA table_info(user_preferences)")
|
||||
existing = {row[1] for row in cur.fetchall()}
|
||||
|
||||
if "mic_unit_pref" not in existing:
|
||||
cur.execute(
|
||||
"ALTER TABLE user_preferences "
|
||||
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
|
||||
)
|
||||
# Backfill any rows where the column ended up NULL.
|
||||
cur.execute(
|
||||
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||
"WHERE mic_unit_pref IS NULL"
|
||||
)
|
||||
print("Added mic_unit_pref column (default 'psi').")
|
||||
else:
|
||||
print("mic_unit_pref column already exists.")
|
||||
|
||||
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
|
||||
# 'dBL' default get bumped to 'psi'. If any operator deliberately
|
||||
# chose 'dBL' through Settings before this migration runs they'd
|
||||
# get reset — acceptable trade-off given the small user base and
|
||||
# the fact the setting is one click to restore.
|
||||
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||
"WHERE mic_unit_pref = 'dBL'")
|
||||
flipped = cur.rowcount
|
||||
if flipped:
|
||||
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
|
||||
|
||||
conn.commit()
|
||||
conn.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.")
|
||||
@@ -135,6 +135,11 @@ class UserPreferences(Base):
|
||||
calibration_warning_days = Column(Integer, default=30)
|
||||
status_ok_threshold_hours = Column(Integer, default=12)
|
||||
status_pending_threshold_hours = Column(Integer, default=24)
|
||||
# Mic display units on the event-report waveform chart only — peaks
|
||||
# and KPI tiles elsewhere are always dBL. "psi" (default — matches
|
||||
# the PDF report) or "dBL". Default flipped in v0.13.1 after
|
||||
# operator feedback that the chart should mirror the PDF.
|
||||
mic_unit_pref = Column(String, default="psi")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -235,6 +240,20 @@ class MonitoringLocation(Base):
|
||||
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
||||
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)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -630,3 +649,58 @@ class JobReservationUnit(Base):
|
||||
# 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)
|
||||
|
||||
@@ -49,6 +49,15 @@ def admin_sfm_page(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -361,6 +361,9 @@ def locations_search(
|
||||
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()
|
||||
)
|
||||
|
||||
@@ -373,7 +376,11 @@ def locations_search(
|
||||
if q_norm in l_norm:
|
||||
scored.append((l, 1.0))
|
||||
continue
|
||||
score = svc.similarity(q_norm, l_norm)
|
||||
# Use the location-specific scorer (token_set_ratio + multi-digit
|
||||
# penalty) instead of WRatio — same reason as the cluster-match
|
||||
# path: location names share too much boilerplate vocabulary for
|
||||
# WRatio to discriminate reliably.
|
||||
score = svc.location_similarity(q_norm, l_norm)
|
||||
if score >= 0.50:
|
||||
scored.append((l, score))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import json
|
||||
@@ -31,9 +32,10 @@ from backend.models import (
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
UnitHistory,
|
||||
ScheduledAction,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.utils.timezone import local_to_utc
|
||||
from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||
|
||||
@@ -138,7 +140,7 @@ async def get_project_locations(
|
||||
):
|
||||
"""
|
||||
Get all monitoring locations for a project.
|
||||
Returns HTML partial with location list.
|
||||
Returns HTML partial with location list, split into active + removed.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
@@ -150,12 +152,35 @@ async def get_project_locations(
|
||||
if location_type:
|
||||
query = query.filter_by(location_type=location_type)
|
||||
|
||||
locations = query.order_by(MonitoringLocation.name).all()
|
||||
# Order by operator-set sort_order, then name as a stable tie-breaker.
|
||||
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||
|
||||
# Enrich with assignment info
|
||||
locations_data = []
|
||||
# For vibration locations, fan out event counts via SFM concurrently
|
||||
# so the card layout can show "{N} events" instead of "Sessions: 0"
|
||||
# (sessions don't really exist for the watcher-forward pipeline).
|
||||
# Sound locations skip this and keep showing session counts.
|
||||
event_counts: dict[str, int] = {}
|
||||
vibration_locations = [l for l in locations if l.location_type == "vibration"]
|
||||
if vibration_locations:
|
||||
import asyncio
|
||||
from backend.services.sfm_events import events_for_location
|
||||
results = await asyncio.gather(
|
||||
*(events_for_location(db, l.id, limit=1) for l in vibration_locations),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for loc, res in zip(vibration_locations, results):
|
||||
if isinstance(res, Exception):
|
||||
continue # leave event_counts[loc.id] unset → template falls back
|
||||
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0
|
||||
|
||||
# Enrich with assignment info, splitting active vs removed.
|
||||
active_data: list = []
|
||||
removed_data: list = []
|
||||
for location in locations:
|
||||
# Get active assignment (active = assigned_until IS NULL)
|
||||
# Get active assignment (active = assigned_until IS NULL). For
|
||||
# removed locations this will normally be None because the
|
||||
# /remove cascade closes them, but check anyway for resilience
|
||||
# against legacy data.
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location.id,
|
||||
@@ -172,17 +197,25 @@ async def get_project_locations(
|
||||
location_id=location.id
|
||||
).count()
|
||||
|
||||
locations_data.append({
|
||||
"location": location,
|
||||
"assignment": assignment,
|
||||
item = {
|
||||
"location": location,
|
||||
"assignment": assignment,
|
||||
"assigned_unit": assigned_unit,
|
||||
"session_count": session_count,
|
||||
})
|
||||
}
|
||||
if location.id in event_counts:
|
||||
item["event_count"] = event_counts[location.id]
|
||||
if location.removed_at is None:
|
||||
active_data.append(item)
|
||||
else:
|
||||
removed_data.append(item)
|
||||
|
||||
return templates.TemplateResponse("partials/projects/location_list.html", {
|
||||
"request": request,
|
||||
"project": project,
|
||||
"locations": locations_data,
|
||||
"request": request,
|
||||
"project": project,
|
||||
"locations": active_data, # back-compat alias
|
||||
"active_locations": active_data,
|
||||
"removed_locations": removed_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -191,10 +224,15 @@ async def get_project_locations_json(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
location_type: Optional[str] = Query(None),
|
||||
include_removed: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Get all monitoring locations for a project as JSON.
|
||||
Used by the schedule modal to populate location dropdown.
|
||||
|
||||
Removed locations are filtered out by default (you can't schedule
|
||||
a new action at a removed location). Pass `include_removed=true`
|
||||
to get them too — useful for historical / reporting views.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
@@ -205,21 +243,104 @@ async def get_project_locations_json(
|
||||
if location_type:
|
||||
query = query.filter_by(location_type=location_type)
|
||||
|
||||
locations = query.order_by(MonitoringLocation.name).all()
|
||||
if not include_removed:
|
||||
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||||
|
||||
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": loc.id,
|
||||
"name": loc.name,
|
||||
"location_type": loc.location_type,
|
||||
"description": loc.description,
|
||||
"address": loc.address,
|
||||
"coordinates": loc.coordinates,
|
||||
"id": loc.id,
|
||||
"name": loc.name,
|
||||
"location_type": loc.location_type,
|
||||
"description": loc.description,
|
||||
"address": loc.address,
|
||||
"coordinates": loc.coordinates,
|
||||
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||||
"removal_reason": loc.removal_reason,
|
||||
}
|
||||
for loc in locations
|
||||
]
|
||||
|
||||
|
||||
@router.get("/locations-with-assignments")
|
||||
async def get_locations_with_assignments(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
location_type: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Locations + their currently-active assignment + current unit + paired modem
|
||||
in one call. Used by the Unit Swap tool's location picker so a field tech
|
||||
can see what's deployed where without N+1 round-trips.
|
||||
|
||||
Empty locations come back with assignment/unit/modem all null.
|
||||
Removed locations are always excluded — you don't swap onto a dead slot.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter(
|
||||
MonitoringLocation.removed_at == None # noqa: E711
|
||||
)
|
||||
if location_type:
|
||||
query = query.filter_by(location_type=location_type)
|
||||
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||
|
||||
results = []
|
||||
for loc in locations:
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == loc.id,
|
||||
UnitAssignment.assigned_until == None, # noqa: E711
|
||||
)
|
||||
).first()
|
||||
|
||||
unit_payload = None
|
||||
modem_payload = None
|
||||
if assignment:
|
||||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
if unit:
|
||||
unit_payload = {
|
||||
"id": unit.id,
|
||||
"device_type": unit.device_type,
|
||||
"unit_type": unit.unit_type,
|
||||
"slm_model": unit.slm_model,
|
||||
"deployed_with_modem_id": unit.deployed_with_modem_id,
|
||||
}
|
||||
if unit.deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(
|
||||
id=unit.deployed_with_modem_id, device_type="modem"
|
||||
).first()
|
||||
if modem:
|
||||
modem_payload = {
|
||||
"id": modem.id,
|
||||
"hardware_model": modem.hardware_model,
|
||||
"ip_address": modem.ip_address,
|
||||
"phone_number": modem.phone_number,
|
||||
"deployed": bool(modem.deployed),
|
||||
}
|
||||
|
||||
results.append({
|
||||
"id": loc.id,
|
||||
"name": loc.name,
|
||||
"location_type": loc.location_type,
|
||||
"description": loc.description,
|
||||
"address": loc.address,
|
||||
"coordinates": loc.coordinates,
|
||||
"assignment": {
|
||||
"id": assignment.id,
|
||||
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
|
||||
"notes": assignment.notes,
|
||||
} if assignment else None,
|
||||
"unit": unit_payload,
|
||||
"modem": modem_payload,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/locations/create")
|
||||
async def create_location(
|
||||
project_id: str,
|
||||
@@ -235,6 +356,13 @@ async def create_location(
|
||||
|
||||
form_data = await request.form()
|
||||
|
||||
# Compute next sort_order so new locations land at the END of the
|
||||
# project's list rather than getting interleaved alphabetically.
|
||||
from sqlalchemy import func
|
||||
max_sort = db.query(func.max(MonitoringLocation.sort_order))\
|
||||
.filter_by(project_id=project_id).scalar()
|
||||
next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0
|
||||
|
||||
location = MonitoringLocation(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
@@ -244,6 +372,7 @@ async def create_location(
|
||||
coordinates=form_data.get("coordinates"),
|
||||
address=form_data.get("address"),
|
||||
location_metadata=form_data.get("location_metadata"), # JSON string
|
||||
sort_order=next_sort_order,
|
||||
)
|
||||
|
||||
db.add(location)
|
||||
@@ -335,6 +464,216 @@ async def delete_location(
|
||||
return {"success": True, "message": "Location deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/locations/reorder")
|
||||
async def reorder_locations(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Persist a new sort order for a project's monitoring locations.
|
||||
|
||||
Body JSON: { "location_ids": [uuid, uuid, ...] }
|
||||
The list MUST contain location ids in the desired display order.
|
||||
Locations not included in the list keep their current sort_order
|
||||
(useful for the "active locations only — leave removed alone"
|
||||
drag-and-drop UX).
|
||||
|
||||
Updates `sort_order` to the index of each id in the list. Ties
|
||||
between included and excluded locations fall back to the existing
|
||||
sort_order.
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
ids = payload.get("location_ids") or []
|
||||
if not isinstance(ids, list) or len(ids) == 0:
|
||||
raise HTTPException(status_code=400, detail="location_ids must be a non-empty list")
|
||||
|
||||
# Fetch only the locations being reordered and validate ownership.
|
||||
locations = db.query(MonitoringLocation).filter(
|
||||
MonitoringLocation.project_id == project_id,
|
||||
MonitoringLocation.id.in_(ids),
|
||||
).all()
|
||||
|
||||
found_ids = {l.id for l in locations}
|
||||
missing = [i for i in ids if i not in found_ids]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Some locations not found in this project: {missing[:3]}…",
|
||||
)
|
||||
|
||||
# Apply 0-indexed sort_order matching the operator's chosen order.
|
||||
by_id = {l.id: l for l in locations}
|
||||
for idx, loc_id in enumerate(ids):
|
||||
by_id[loc_id].sort_order = idx
|
||||
|
||||
db.commit()
|
||||
return {"success": True, "reordered": len(ids)}
|
||||
|
||||
|
||||
@router.post("/locations/{location_id}/remove")
|
||||
async def remove_location(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Soft-remove a monitoring location — mark it as no longer actively
|
||||
monitored without destroying it.
|
||||
|
||||
Use case: a client drops a location from scope mid-project, but the
|
||||
historical events recorded there should remain attributed. Deleting
|
||||
would orphan those events; this preserves them.
|
||||
|
||||
Cascading side-effects:
|
||||
1. All active UnitAssignment rows at this location are closed
|
||||
(assigned_until = effective_date, status = "completed").
|
||||
Units become available for other deployments.
|
||||
2. All pending ScheduledAction rows at this location are cancelled
|
||||
(execution_status = "cancelled").
|
||||
3. Historical events stay attributed (attribution is window-based;
|
||||
events with timestamp < effective_date still match the
|
||||
now-closed assignment windows).
|
||||
|
||||
Accepts JSON body:
|
||||
- effective_date: ISO datetime (optional, defaults to now)
|
||||
- reason: operator note (optional)
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
if location.removed_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location is already removed (as of {location.removed_at.isoformat()}).",
|
||||
)
|
||||
|
||||
# Body is optional — POST with no body is fine and means "remove now,
|
||||
# no reason given."
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
# Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or
|
||||
# full ISO. Defaults to now if absent/empty.
|
||||
raw_eff = payload.get("effective_date")
|
||||
if raw_eff:
|
||||
try:
|
||||
effective_date = datetime.fromisoformat(raw_eff)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid effective_date: {raw_eff!r}",
|
||||
)
|
||||
else:
|
||||
effective_date = datetime.utcnow()
|
||||
|
||||
reason = (payload.get("reason") or "").strip() or None
|
||||
|
||||
# 1. Close active assignments at this location.
|
||||
active_assignments = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.assigned_until == None, # noqa: E711 — SQL NULL
|
||||
)
|
||||
).all()
|
||||
|
||||
for a in active_assignments:
|
||||
a.status = "completed"
|
||||
a.assigned_until = effective_date
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=a.unit_id,
|
||||
change_type="assignment_ended",
|
||||
old_value=location.name,
|
||||
new_value="location removed",
|
||||
notes=f"Location '{location.name}' marked as removed"
|
||||
+ (f" — {reason}" if reason else ""),
|
||||
)
|
||||
|
||||
# 2. Cancel pending scheduled actions at this location.
|
||||
pending_actions = db.query(ScheduledAction).filter(
|
||||
and_(
|
||||
ScheduledAction.location_id == location_id,
|
||||
ScheduledAction.execution_status == "pending",
|
||||
ScheduledAction.scheduled_time >= effective_date,
|
||||
)
|
||||
).all()
|
||||
|
||||
for sa in pending_actions:
|
||||
sa.execution_status = "cancelled"
|
||||
sa.error_message = (
|
||||
f"Cancelled: location '{location.name}' marked as removed"
|
||||
+ (f" — {reason}" if reason else "")
|
||||
)
|
||||
|
||||
# 3. Mark the location itself as removed.
|
||||
location.removed_at = effective_date
|
||||
location.removal_reason = reason
|
||||
location.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Location '{location.name}' marked as removed",
|
||||
"effective_date": effective_date.isoformat(),
|
||||
"assignments_closed": len(active_assignments),
|
||||
"actions_cancelled": len(pending_actions),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/locations/{location_id}/restore")
|
||||
async def restore_location(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Restore a previously-removed monitoring location to active.
|
||||
|
||||
Clears `removed_at` and `removal_reason`. Does NOT automatically
|
||||
re-open the assignments or scheduled actions that were closed when
|
||||
the location was removed — those stay closed and the operator can
|
||||
create new ones if they want to resume monitoring.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
if location.removed_at is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Location is already active.",
|
||||
)
|
||||
|
||||
location.removed_at = None
|
||||
location.removal_reason = None
|
||||
location.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Location '{location.name}' restored to active",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Assignments
|
||||
# ============================================================================
|
||||
@@ -385,6 +724,19 @@ async def assign_unit_to_location(
|
||||
):
|
||||
"""
|
||||
Assign a unit to a monitoring location.
|
||||
|
||||
Accepts form fields:
|
||||
- unit_id — required
|
||||
- assigned_at — optional ISO datetime; defaults to now. Set this
|
||||
when backfilling a historical deployment whose
|
||||
events landed in the orphan bucket.
|
||||
- assigned_until — optional ISO datetime; absent = open-ended /
|
||||
active.
|
||||
- notes — optional free text
|
||||
|
||||
Refuses only when the *new window would overlap* an existing active
|
||||
open-ended assignment at the same location. Closed historical windows
|
||||
that don't overlap are allowed (and required for orphan-event backfill).
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
@@ -410,23 +762,50 @@ async def assign_unit_to_location(
|
||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||
)
|
||||
|
||||
# Check if location already has an active assignment (active = assigned_until IS NULL)
|
||||
existing_assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.assigned_until == None,
|
||||
)
|
||||
).first()
|
||||
# Parse dates. Naive datetimes from datetime-local inputs are
|
||||
# interpreted as user-local and converted to UTC for storage; explicit
|
||||
# tz-aware ISO strings (Z / +00:00) skip the conversion.
|
||||
def _parse_user_dt(s: str | None, field: str):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(s)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}")
|
||||
if parsed.tzinfo is None:
|
||||
return local_to_utc(parsed)
|
||||
return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||
|
||||
if existing_assignment:
|
||||
assigned_at = _parse_user_dt(form_data.get("assigned_at"), "assigned_at") or datetime.utcnow()
|
||||
assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until")
|
||||
if assigned_until is not None and assigned_until <= assigned_at:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
||||
status_code=400, detail="assigned_until must be after assigned_at.",
|
||||
)
|
||||
|
||||
# Create new assignment
|
||||
assigned_until_str = form_data.get("assigned_until")
|
||||
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
|
||||
# Reject only if the new window overlaps an existing assignment at the
|
||||
# SAME location. Closed historical windows that sit before the current
|
||||
# active assignment are fine — that's the backfill case.
|
||||
new_end_for_overlap = assigned_until or datetime.utcnow()
|
||||
existing = db.query(UnitAssignment).filter(
|
||||
UnitAssignment.location_id == location_id
|
||||
).all()
|
||||
for other in existing:
|
||||
other_start = other.assigned_at
|
||||
other_end = other.assigned_until or datetime.utcnow()
|
||||
if assigned_at < other_end and new_end_for_overlap > other_start:
|
||||
other_window = (
|
||||
f"{other.assigned_at:%Y-%m-%d}"
|
||||
+ (f" → {other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"New window overlaps an existing assignment at this "
|
||||
f"location ({other.unit_id} {other_window}). Use swap or "
|
||||
f"edit that record instead."
|
||||
),
|
||||
)
|
||||
|
||||
assignment = UnitAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -434,8 +813,9 @@ async def assign_unit_to_location(
|
||||
location_id=location_id,
|
||||
project_id=project_id,
|
||||
device_type=unit.device_type,
|
||||
assigned_at=assigned_at,
|
||||
assigned_until=assigned_until,
|
||||
status="active",
|
||||
status="active" if assigned_until is None else "completed",
|
||||
notes=form_data.get("notes"),
|
||||
)
|
||||
|
||||
@@ -492,6 +872,20 @@ async def unassign_unit(
|
||||
assignment.status = "completed"
|
||||
assignment.assigned_until = datetime.utcnow()
|
||||
|
||||
# Unit is leaving the field — bench it so the heartbeat / polling
|
||||
# subsystem stops chasing it. Also break the modem pairing both ways.
|
||||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
if unit:
|
||||
if unit.deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(
|
||||
id=unit.deployed_with_modem_id, device_type="modem"
|
||||
).first()
|
||||
if modem and modem.deployed_with_unit_id == unit.id:
|
||||
modem.deployed_with_unit_id = None
|
||||
unit.deployed_with_modem_id = None
|
||||
if unit.deployed:
|
||||
unit.deployed = False
|
||||
|
||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||
_record_assignment_history(
|
||||
db,
|
||||
@@ -526,6 +920,11 @@ async def update_assignment(
|
||||
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
||||
- notes: string
|
||||
|
||||
Naive datetimes (no tz suffix) are interpreted as the user's
|
||||
configured timezone and converted to UTC for storage. Send an
|
||||
explicit "+00:00" / "Z" suffix to skip the conversion (programmatic
|
||||
callers that already have UTC).
|
||||
|
||||
Sets `status` to "active" when assigned_until is cleared, "completed"
|
||||
when it's set in the past.
|
||||
"""
|
||||
@@ -556,12 +955,14 @@ async def update_assignment(
|
||||
)
|
||||
try:
|
||||
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
||||
new_assigned_at = datetime.fromisoformat(raw)
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid assigned_at datetime: {raw!r}",
|
||||
)
|
||||
# Naive (no tz) → treat as user's local time and store as UTC.
|
||||
new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||
|
||||
if "assigned_until" in payload:
|
||||
raw = payload["assigned_until"]
|
||||
@@ -569,12 +970,13 @@ async def update_assignment(
|
||||
new_assigned_until = None
|
||||
else:
|
||||
try:
|
||||
new_assigned_until = datetime.fromisoformat(raw)
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid assigned_until datetime: {raw!r}",
|
||||
)
|
||||
new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||
|
||||
if "notes" in payload:
|
||||
raw = payload["notes"]
|
||||
@@ -650,6 +1052,228 @@ async def update_assignment(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/assignments/{assignment_id}")
|
||||
async def delete_assignment(
|
||||
project_id: str,
|
||||
assignment_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Hard-delete an assignment record.
|
||||
|
||||
Use case: operator clicked Assign by mistake (or 8 times in a row) and
|
||||
wants the bogus records gone — not just closed with an `assigned_until`
|
||||
timestamp. The standard close-via-unassign path is for legitimate
|
||||
deployments that ended; this is for mis-clicks that never actually
|
||||
happened.
|
||||
|
||||
Safety:
|
||||
- Refuses if any MonitoringSession exists for the same (unit, location)
|
||||
within this assignment's window — that suggests the deployment was
|
||||
real, and the operator should use unassign instead.
|
||||
- Refuses if the assignment is the ONLY active assignment for a unit
|
||||
currently shown as deployed AND a recording session is in progress.
|
||||
|
||||
Audit:
|
||||
- Records UnitHistory `assignment_deleted` so the unit's deployment
|
||||
timeline shows the deletion happened (even though the row itself
|
||||
is gone).
|
||||
"""
|
||||
assignment = db.query(UnitAssignment).filter_by(
|
||||
id=assignment_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Safety: is there a real recording history for this (unit, location)
|
||||
# within the assignment's time window? If so, this isn't a mis-click —
|
||||
# the operator should close it via unassign, not delete it.
|
||||
window_start = assignment.assigned_at
|
||||
window_end = assignment.assigned_until or datetime.utcnow()
|
||||
real_sessions = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
MonitoringSession.location_id == assignment.location_id,
|
||||
MonitoringSession.unit_id == assignment.unit_id,
|
||||
MonitoringSession.started_at >= window_start,
|
||||
MonitoringSession.started_at <= window_end,
|
||||
)
|
||||
).count()
|
||||
|
||||
if real_sessions > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Cannot delete this assignment — {real_sessions} monitoring "
|
||||
f"session(s) were recorded under it. Use Unassign to close "
|
||||
f"the window instead, which preserves the audit trail."
|
||||
),
|
||||
)
|
||||
|
||||
# Resolve location name for audit log before deletion.
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=assignment.location_id
|
||||
).first()
|
||||
location_label = location.name if location else assignment.location_id
|
||||
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=assignment.unit_id,
|
||||
change_type="assignment_deleted",
|
||||
old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d} → "
|
||||
f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})",
|
||||
new_value="deleted",
|
||||
notes=(
|
||||
"Assignment row removed — created in error or accidental duplicate."
|
||||
),
|
||||
)
|
||||
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Assignment deleted.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assignments/merge")
|
||||
async def merge_assignments(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Merge multiple consecutive UnitAssignment rows for the same (unit, location)
|
||||
into a single record spanning their combined window.
|
||||
|
||||
Use case: a unit's deployment timeline shows 3 stacked rows for the
|
||||
same location because the assignment was closed-and-reopened (e.g. via
|
||||
location remove + restore) or because metadata-backfill auto-created
|
||||
a retroactive window adjacent to a manual one. Operator sees three
|
||||
rows but they represent one continuous deployment.
|
||||
|
||||
Body JSON:
|
||||
{ "assignment_ids": ["<uuid>", "<uuid>", ...] }
|
||||
|
||||
Validation:
|
||||
- All assignments must belong to project_id
|
||||
- All must share the same unit_id AND location_id
|
||||
- At least 2 ids must be provided
|
||||
|
||||
Merge rules:
|
||||
- Keeps the EARLIEST-starting assignment as the surviving row
|
||||
- assigned_at = min(assigned_at across all)
|
||||
- assigned_until = max(assigned_until), or NULL if any input was active
|
||||
- status = "active" if any input was active, else "completed"
|
||||
- source = source of the earliest record (preserves original ingest provenance)
|
||||
- notes = earliest's notes + "Merged N records (<sources>)"
|
||||
- Other records are DELETED
|
||||
- One UnitHistory `assignment_merged` row is written for audit
|
||||
|
||||
No tolerance check — the operator is asking for the merge, so we trust
|
||||
the intent. The UI can pre-filter to only offer "consecutive" merges
|
||||
if it wants.
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
ids = payload.get("assignment_ids") or []
|
||||
if not isinstance(ids, list) or len(ids) < 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Need at least 2 assignment_ids to merge.",
|
||||
)
|
||||
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.project_id == project_id)
|
||||
.filter(UnitAssignment.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(assignments) != len(set(ids)):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).",
|
||||
)
|
||||
|
||||
unit_ids = {a.unit_id for a in assignments}
|
||||
loc_ids = {a.location_id for a in assignments}
|
||||
if len(unit_ids) > 1 or len(loc_ids) > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Can only merge assignments that share the same unit and location.",
|
||||
)
|
||||
|
||||
# Order chronologically.
|
||||
assignments.sort(key=lambda a: a.assigned_at)
|
||||
earliest = assignments[0]
|
||||
others = assignments[1:]
|
||||
|
||||
# Compute merged window.
|
||||
any_active = any(a.assigned_until is None for a in assignments)
|
||||
if any_active:
|
||||
merged_until = None
|
||||
else:
|
||||
merged_until = max(a.assigned_until for a in assignments)
|
||||
|
||||
# Build a brief audit-style note describing what got merged.
|
||||
bits = []
|
||||
for a in assignments:
|
||||
win = (
|
||||
f"{a.assigned_at:%Y-%m-%d}"
|
||||
f"→{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||
)
|
||||
bits.append(f"{win} [{a.source}]")
|
||||
merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits)
|
||||
new_notes = (earliest.notes + " • " + merge_note_suffix) if earliest.notes else merge_note_suffix
|
||||
|
||||
# Resolve names for the audit log before mutating.
|
||||
location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first()
|
||||
location_label = location.name if location else earliest.location_id
|
||||
|
||||
# Mutate the survivor.
|
||||
earliest.assigned_at = min(a.assigned_at for a in assignments)
|
||||
earliest.assigned_until = merged_until
|
||||
earliest.status = "active" if any_active else "completed"
|
||||
earliest.notes = new_notes
|
||||
|
||||
# Delete the rest.
|
||||
deleted_ids = [a.id for a in others]
|
||||
for a in others:
|
||||
db.delete(a)
|
||||
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=earliest.unit_id,
|
||||
change_type="assignment_merged",
|
||||
old_value=f"{len(assignments)} rows at {location_label}",
|
||||
new_value=(
|
||||
f"1 row {earliest.assigned_at:%Y-%m-%d}"
|
||||
f"→{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||
),
|
||||
notes=merge_note_suffix,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(earliest)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Merged {len(assignments)} assignments into one.",
|
||||
"kept_id": earliest.id,
|
||||
"deleted_ids": deleted_ids,
|
||||
"merged_window": {
|
||||
"assigned_at": earliest.assigned_at.isoformat(),
|
||||
"assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/locations/{location_id}/swap")
|
||||
async def swap_unit_on_location(
|
||||
project_id: str,
|
||||
@@ -710,6 +1334,22 @@ async def swap_unit_on_location(
|
||||
new_value=f"swapped out → {unit_id}",
|
||||
notes=notes,
|
||||
)
|
||||
# Clear the outgoing unit's modem pairing so the bidirectional
|
||||
# deployed_with_modem_id / deployed_with_unit_id back-reference
|
||||
# doesn't orphan onto the unit that just left the field.
|
||||
old_unit = db.query(RosterUnit).filter_by(id=current.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 == current.unit_id:
|
||||
old_modem.deployed_with_unit_id = None
|
||||
old_unit.deployed_with_modem_id = None
|
||||
# Bench the outgoing unit — it's no longer in the field, so
|
||||
# the heartbeat / polling subsystem should stop chasing it.
|
||||
if old_unit.deployed:
|
||||
old_unit.deployed = False
|
||||
|
||||
# Create new assignment
|
||||
new_assignment = UnitAssignment(
|
||||
@@ -736,12 +1376,28 @@ async def swap_unit_on_location(
|
||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||
if not modem:
|
||||
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
||||
# Symmetric cleanup: if this modem still claims a previous partner
|
||||
# (a different seismograph whose deployed_with_modem_id never got
|
||||
# cleared in a past swap), break that stale link before re-pairing.
|
||||
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id:
|
||||
prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first()
|
||||
if prev_partner and prev_partner.deployed_with_modem_id == modem_id:
|
||||
prev_partner.deployed_with_modem_id = None
|
||||
unit.deployed_with_modem_id = modem_id
|
||||
modem.deployed_with_unit_id = unit_id
|
||||
# If the modem was on the bench, swapping it into the field puts it
|
||||
# back in rotation.
|
||||
if not modem.deployed:
|
||||
modem.deployed = True
|
||||
else:
|
||||
# Clear modem pairing if not provided
|
||||
unit.deployed_with_modem_id = None
|
||||
|
||||
# If the incoming unit was benched, putting it in the field flips it
|
||||
# back to deployed (so polling / dashboards see it as in rotation).
|
||||
if not unit.deployed:
|
||||
unit.deployed = True
|
||||
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
@@ -759,23 +1415,32 @@ async def swap_unit_on_location(
|
||||
async def get_available_modems(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
include_benched: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Get all deployed, non-retired modems for the modem assignment dropdown.
|
||||
Get all non-retired modems for the modem assignment dropdown.
|
||||
|
||||
By default only deployed (in-rotation) modems are returned, preserving
|
||||
the existing behavior for callers like the location-detail swap modal.
|
||||
Pass ``include_benched=true`` to also include benched modems
|
||||
(``RosterUnit.deployed == False``) — useful when picking a modem to
|
||||
pull off the bench for a field swap. Each row's ``deployed`` flag is
|
||||
returned so the UI can badge benched candidates.
|
||||
"""
|
||||
modems = db.query(RosterUnit).filter(
|
||||
and_(
|
||||
RosterUnit.device_type == "modem",
|
||||
RosterUnit.deployed == True,
|
||||
RosterUnit.retired == False,
|
||||
)
|
||||
).order_by(RosterUnit.id).all()
|
||||
filters = [
|
||||
RosterUnit.device_type == "modem",
|
||||
RosterUnit.retired == False,
|
||||
]
|
||||
if not include_benched:
|
||||
filters.append(RosterUnit.deployed == True)
|
||||
modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"hardware_model": m.hardware_model,
|
||||
"ip_address": m.ip_address,
|
||||
"deployed": bool(m.deployed),
|
||||
}
|
||||
for m in modems
|
||||
]
|
||||
@@ -786,22 +1451,31 @@ async def get_available_units(
|
||||
project_id: str,
|
||||
location_type: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
include_benched: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Get list of available units for assignment to a location.
|
||||
Filters by device type matching the location type.
|
||||
|
||||
By default only deployed (in-rotation) units are returned, preserving
|
||||
the existing location-detail swap-modal behavior. Pass
|
||||
``include_benched=true`` to also include benched units
|
||||
(``RosterUnit.deployed == False``) — exactly the candidates you'd
|
||||
pull off the bench for a field swap. Each row carries a ``deployed``
|
||||
flag so the UI can badge benched picks.
|
||||
"""
|
||||
# Determine required device type
|
||||
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
||||
|
||||
# Get all units of the required type that are deployed and not retired
|
||||
all_units = db.query(RosterUnit).filter(
|
||||
and_(
|
||||
RosterUnit.device_type == required_device_type,
|
||||
RosterUnit.deployed == True,
|
||||
RosterUnit.retired == False,
|
||||
)
|
||||
).all()
|
||||
# Get all units of the required type that aren't retired (and optionally
|
||||
# exclude benched units).
|
||||
filters = [
|
||||
RosterUnit.device_type == required_device_type,
|
||||
RosterUnit.retired == False,
|
||||
]
|
||||
if not include_benched:
|
||||
filters.append(RosterUnit.deployed == True)
|
||||
all_units = db.query(RosterUnit).filter(and_(*filters)).all()
|
||||
|
||||
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||
@@ -815,6 +1489,7 @@ async def get_available_units(
|
||||
"device_type": unit.device_type,
|
||||
"location": unit.address or unit.location,
|
||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||
"deployed": bool(unit.deployed),
|
||||
}
|
||||
for unit in all_units
|
||||
if unit.id not in assigned_unit_ids
|
||||
|
||||
@@ -267,6 +267,7 @@ class PreferencesUpdate(BaseModel):
|
||||
calibration_warning_days: Optional[int] = None
|
||||
status_ok_threshold_hours: Optional[int] = None
|
||||
status_pending_threshold_hours: Optional[int] = None
|
||||
mic_unit_pref: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
@@ -293,6 +294,7 @@ def get_preferences(db: Session = Depends(get_db)):
|
||||
"calibration_warning_days": prefs.calibration_warning_days,
|
||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||
}
|
||||
|
||||
@@ -334,6 +336,7 @@ def update_preferences(
|
||||
"calibration_warning_days": prefs.calibration_warning_days,
|
||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -39,13 +39,46 @@ from backend.services.sfm_events import (
|
||||
_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
|
||||
@@ -178,8 +211,8 @@ async def deployment_timeline_for_unit(
|
||||
overlays[a.id] = {
|
||||
"event_count": len(events),
|
||||
"peak_pvs": peak,
|
||||
"peak_pvs_at": peak_at,
|
||||
"last_event": last_ev,
|
||||
"peak_pvs_at": _iso_local(peak_at),
|
||||
"last_event": _iso_local(last_ev),
|
||||
}
|
||||
|
||||
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||
@@ -195,8 +228,8 @@ async def deployment_timeline_for_unit(
|
||||
|
||||
entry = {
|
||||
"kind": "assignment",
|
||||
"starts_at": _iso_utc(a.assigned_at),
|
||||
"ends_at": _iso_utc(a.assigned_until),
|
||||
"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,
|
||||
@@ -220,8 +253,8 @@ async def deployment_timeline_for_unit(
|
||||
if gap_seconds >= _MIN_GAP_SECONDS:
|
||||
entries.append({
|
||||
"kind": "gap",
|
||||
"starts_at": _iso_utc(gap_start),
|
||||
"ends_at": _iso_utc(gap_end),
|
||||
"starts_at": _iso_local(gap_start),
|
||||
"ends_at": _iso_local(gap_end),
|
||||
"duration_days": round(gap_seconds / 86400, 1),
|
||||
"context": "between assignments",
|
||||
})
|
||||
@@ -234,7 +267,7 @@ async def deployment_timeline_for_unit(
|
||||
continue
|
||||
entries.append({
|
||||
"kind": "state_change",
|
||||
"starts_at": _iso_utc(h.changed_at),
|
||||
"starts_at": _iso_local(h.changed_at),
|
||||
"ends_at": None,
|
||||
"duration_days": None,
|
||||
"change_type": h.change_type,
|
||||
@@ -245,12 +278,41 @@ async def deployment_timeline_for_unit(
|
||||
"history_notes": h.notes,
|
||||
})
|
||||
|
||||
# 6. Sort newest first. Active assignments (no end) sort by start time,
|
||||
# 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,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -162,6 +162,11 @@ def similarity(a: str, b: str) -> float:
|
||||
too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the
|
||||
strings don't exact-match. This guardrails the 'one common word
|
||||
inside a longer phrase' false positive.
|
||||
|
||||
USE FOR: project names (where typos like '1-80' vs 'I-80' should
|
||||
still match). For location names use `location_similarity()` —
|
||||
WRatio is too lenient on the shared boilerplate vocabulary in
|
||||
location strings ('Area', 'Loc', 'Bridge', 'Dam', etc.).
|
||||
"""
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
@@ -172,6 +177,50 @@ def similarity(a: str, b: str) -> float:
|
||||
return rapidfuzz.fuzz.WRatio(a, b) / 100.0
|
||||
|
||||
|
||||
# Multi-digit penalty applied when two location names have completely
|
||||
# disjoint multi-digit numeric tokens (e.g. "87 Jenks" vs "68 Jenks").
|
||||
# Single-digit numbers ("Loc 1", "Area 2") are often shared coincidentally,
|
||||
# but address-style multi-digit numbers are strong identifiers — if they
|
||||
# differ, the locations are usually different physical places.
|
||||
_LOCATION_DIGIT_MISMATCH_PENALTY = 0.30
|
||||
|
||||
|
||||
def location_similarity(a: str, b: str) -> float:
|
||||
"""Stricter similarity score for location-name matching.
|
||||
|
||||
Location names share so much boilerplate vocabulary ('Area', 'Loc',
|
||||
'Bridge', 'Dam') that rapidfuzz.WRatio inflates obvious mismatches.
|
||||
Example: 'Area 2 - Brookville Dam - Loc 2 East' vs 'Area 1 - Loc 1 -
|
||||
87 Jenks' scores 85.5 via WRatio despite being unrelated locations.
|
||||
|
||||
This scorer uses `token_set_ratio` as the base (sensitive to actual
|
||||
word overlap, not just substring containment). It then applies a
|
||||
multi-digit penalty: if both strings contain 2+-digit numbers and
|
||||
none overlap, subtract 0.30. Catches the "same project, different
|
||||
address-style identifier" case ('87 Jenks' vs '68 Jenks') that pure
|
||||
token-set scoring still rates above 0.90.
|
||||
|
||||
Single-digit numbers ('Loc 1', 'Area 2') are excluded from the
|
||||
penalty because they're often shared boilerplate ("Loc 1" in every
|
||||
project) rather than discriminating identifiers.
|
||||
"""
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
if min(len(a), len(b)) < _MIN_FUZZY_LEN:
|
||||
return 0.0
|
||||
|
||||
base = rapidfuzz.fuzz.token_set_ratio(a, b) / 100.0
|
||||
|
||||
multidigits_a = set(re.findall(r"\d{2,}", a))
|
||||
multidigits_b = set(re.findall(r"\d{2,}", b))
|
||||
if multidigits_a and multidigits_b and not (multidigits_a & multidigits_b):
|
||||
base = max(0.0, base - _LOCATION_DIGIT_MISMATCH_PENALTY)
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# ── Cluster + Suggestion dataclasses ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -572,15 +621,24 @@ async def _scan_clusters(
|
||||
def _find_best_match(
|
||||
candidate_norm: str,
|
||||
candidates: list[tuple[str, str]], # (id, normalised_name)
|
||||
*,
|
||||
kind: str = "project", # "project" | "location"
|
||||
) -> tuple[Optional[str], Optional[float], str]:
|
||||
"""Return (best_id, best_score, classification).
|
||||
|
||||
classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"}
|
||||
|
||||
The `kind` parameter selects the scorer. Project matching uses
|
||||
rapidfuzz.WRatio (lenient — catches typos like '1-80' vs 'I-80').
|
||||
Location matching uses `location_similarity` (stricter — catches
|
||||
boilerplate-shared-but-actually-different strings like 'Loc 2 - 68
|
||||
Jenks' vs 'Loc 1 - 87 Jenks').
|
||||
"""
|
||||
if not candidate_norm or not candidates:
|
||||
return None, None, "no_match"
|
||||
|
||||
scored = [(cid, similarity(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
||||
scorer = location_similarity if kind == "location" else similarity
|
||||
scored = [(cid, scorer(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
best_id, best_score = scored[0]
|
||||
|
||||
@@ -725,7 +783,7 @@ def _build_suggestion(db: Session, cluster: Cluster) -> Suggestion:
|
||||
)
|
||||
location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs]
|
||||
if cluster.location_norm:
|
||||
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates)
|
||||
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates, kind="location")
|
||||
else:
|
||||
loc_id, loc_score, loc_match = None, None, "create_new"
|
||||
else:
|
||||
|
||||
@@ -318,13 +318,18 @@ async def events_for_unit(
|
||||
loc = loc_map.get(a.location_id)
|
||||
proj = proj_map.get(a.project_id)
|
||||
return {
|
||||
"assignment_id": a.id,
|
||||
"location_id": a.location_id,
|
||||
"location_name": loc.name if loc else None,
|
||||
"project_id": a.project_id,
|
||||
"project_name": proj.name if proj else None,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
"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.
|
||||
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
|
||||
"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)
|
||||
|
||||
+474
-10
@@ -28,6 +28,27 @@
|
||||
(function () {
|
||||
const MODAL_ID = 'event-detail-modal';
|
||||
|
||||
// ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ──
|
||||
const _CHANNEL_COLORS = {
|
||||
MicL: '#e066ff', // purple — distinct from the geo channels
|
||||
Long: '#3b82f6', // blue
|
||||
Vert: '#22c55e', // green
|
||||
Tran: '#ef4444', // red
|
||||
};
|
||||
const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
|
||||
|
||||
// dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit).
|
||||
const DBL_REF = 2.9e-9;
|
||||
// Mic display floor — sound-pressure AC samples sit at the digitisation
|
||||
// noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without
|
||||
// a floor, the chart looks like a sparse pattern of "moments when sound
|
||||
// briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve.
|
||||
const MIC_DBL_FLOOR = 60;
|
||||
|
||||
let _charts = {}; // ch → Chart instance
|
||||
let _micUnitPref = 'psi'; // refreshed via fetch on first chart render
|
||||
let _micUnitPrefLoaded = false; // one-shot fetch guard
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&')
|
||||
@@ -224,32 +245,370 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderReview(s, eventId) {
|
||||
const rev = s.review || {};
|
||||
const ft = !!rev.false_trigger;
|
||||
const reviewer = rev.reviewer || '';
|
||||
const notes = rev.notes || '';
|
||||
const reviewedAt = rev.reviewed_at
|
||||
? rev.reviewed_at.replace('T', ' ').slice(0, 19)
|
||||
: null;
|
||||
return `<div class="bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" id="event-review-ft" ${ft ? 'checked' : ''}
|
||||
class="w-4 h-4 rounded text-seismo-orange focus:ring-seismo-orange">
|
||||
<span class="font-medium">Flag as false trigger</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2 text-sm flex-1 min-w-[180px]">
|
||||
<label for="event-review-reviewer" class="text-gray-500">Reviewer</label>
|
||||
<input type="text" id="event-review-reviewer" value="${_esc(reviewer)}"
|
||||
placeholder="Initials or name"
|
||||
class="flex-1 px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="event-review-notes" class="block text-xs text-gray-500 mb-1">Notes</label>
|
||||
<textarea id="event-review-notes" rows="2"
|
||||
placeholder="Optional context — what caused the FT, follow-up actions, etc."
|
||||
class="w-full px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">${_esc(notes)}</textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 mt-3">
|
||||
<span id="event-review-status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
|
||||
</span>
|
||||
<button type="button"
|
||||
onclick="window.saveEventReview('${_esc(eventId)}')"
|
||||
class="px-4 py-1.5 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Waveform / histogram chart helpers ──────────────────────────
|
||||
|
||||
async function _loadMicUnitPref() {
|
||||
if (_micUnitPrefLoaded) return _micUnitPref;
|
||||
try {
|
||||
const r = await fetch('/api/settings/preferences');
|
||||
if (r.ok) {
|
||||
const prefs = await r.json();
|
||||
_micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi';
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error → silent fall back to default 'psi'.
|
||||
}
|
||||
_micUnitPrefLoaded = true;
|
||||
return _micUnitPref;
|
||||
}
|
||||
|
||||
function _psiToDbl(psi) {
|
||||
if (psi == null || !(psi > 0)) return null;
|
||||
return 20 * Math.log10(psi / DBL_REF);
|
||||
}
|
||||
|
||||
// Rectifying psi→dBL converter for per-sample values — see comments in
|
||||
// sfm_webapp.html:2592-2607 for the floor rationale.
|
||||
function _psiToDblForChart(psi) {
|
||||
if (psi == null) return MIC_DBL_FLOOR;
|
||||
const a = Math.abs(psi);
|
||||
if (a === 0) return MIC_DBL_FLOOR;
|
||||
const dbl = 20 * Math.log10(a / DBL_REF);
|
||||
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
|
||||
}
|
||||
|
||||
// Adaptive decimal formatter — sensible precision in the normal range,
|
||||
// scientific notation only at the extremes.
|
||||
function _fmtPeak(v, unit) {
|
||||
if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
|
||||
if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
|
||||
if (v === 0) return '0' + (unit ? ' ' + unit : '');
|
||||
const a = Math.abs(v);
|
||||
const u = unit ? ' ' + unit : '';
|
||||
if (a >= 0.0001 && a < 10000) {
|
||||
const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
|
||||
return v.toFixed(d) + u;
|
||||
}
|
||||
return v.toExponential(2) + u;
|
||||
}
|
||||
|
||||
function _destroyCharts() {
|
||||
Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
|
||||
_charts = {};
|
||||
}
|
||||
|
||||
// Returns true when Tailwind dark mode is active (the `dark` class is
|
||||
// toggled on <html> by Terra-View's theme handler). Drives chart grid
|
||||
// + tick colors so they have contrast on both backgrounds.
|
||||
function _isDark() {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
}
|
||||
|
||||
function _renderWaveformInto(containerId, data, micUnit) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
_destroyCharts();
|
||||
|
||||
const channels = data.channels || {};
|
||||
const ta = data.time_axis || {};
|
||||
const sr = ta.sample_rate || 1024;
|
||||
const dtMs = ta.dt_ms || (1000.0 / sr);
|
||||
const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
|
||||
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
|
||||
|
||||
const withData = _CHANNEL_ORDER.filter(ch =>
|
||||
channels[ch] && (channels[ch].values || []).length > 0
|
||||
);
|
||||
const lastCh = withData[withData.length - 1];
|
||||
|
||||
// Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
|
||||
// light is white. Grids + ticks need contrast on both.
|
||||
const dark = _isDark();
|
||||
const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
const tickColor = dark ? '#94a3b8' : '#64748b';
|
||||
|
||||
if (withData.length === 0) {
|
||||
container.innerHTML = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
|
||||
No waveform samples decoded — codec walker returned 0 valid blocks for this event.
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ch of _CHANNEL_ORDER) {
|
||||
const chData = channels[ch];
|
||||
if (!chData) continue;
|
||||
let values = chData.values || [];
|
||||
let chUnit = chData.unit || '';
|
||||
let chPeak = chData.peak;
|
||||
|
||||
// Mic: convert psi → dBL when the user pref is dBL (default).
|
||||
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
|
||||
values = values.map(_psiToDblForChart);
|
||||
chPeak = _psiToDbl(chPeak);
|
||||
chUnit = 'dB(L)';
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
|
||||
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
|
||||
lbl.style.color = _CHANNEL_COLORS[ch];
|
||||
const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
|
||||
lbl.innerHTML = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
|
||||
wrap.appendChild(lbl);
|
||||
|
||||
if (values.length === 0) {
|
||||
const e = document.createElement('div');
|
||||
e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
|
||||
e.textContent = 'no samples decoded';
|
||||
wrap.appendChild(e);
|
||||
container.appendChild(wrap);
|
||||
continue;
|
||||
}
|
||||
|
||||
const canvasWrap = document.createElement('div');
|
||||
canvasWrap.className = 'relative';
|
||||
canvasWrap.style.height = '100px';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvasWrap.appendChild(canvas);
|
||||
wrap.appendChild(canvasWrap);
|
||||
container.appendChild(wrap);
|
||||
|
||||
// X-axis: waveforms use ms-relative-to-trigger; histograms use
|
||||
// the BW-reported interval timestamps (HH:MM:SS) when the server
|
||||
// aggregated to BW intervals, else interval index.
|
||||
let times;
|
||||
if (isHistogram) {
|
||||
const intervalTimes = ta.interval_times || [];
|
||||
times = (intervalTimes.length === values.length)
|
||||
? intervalTimes
|
||||
: values.map((_, i) => i + 1);
|
||||
} else {
|
||||
times = values.map((_, i) => t0Ms + i * dtMs);
|
||||
}
|
||||
|
||||
// Downsample for rendering when very long.
|
||||
const MAX = 3000;
|
||||
let rT = times, rV = values;
|
||||
if (values.length > MAX) {
|
||||
const step = Math.ceil(values.length / MAX);
|
||||
rT = times.filter((_, i) => i % step === 0);
|
||||
rV = values.filter((_, i) => i % step === 0);
|
||||
}
|
||||
const showX = (ch === lastCh);
|
||||
|
||||
const xAxisLabel = isHistogram ? '' : ' ms';
|
||||
const fmtTick = i => {
|
||||
const v = rT[i];
|
||||
if (typeof v === 'number') {
|
||||
const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
|
||||
return s + xAxisLabel;
|
||||
}
|
||||
return String(v) + xAxisLabel;
|
||||
};
|
||||
|
||||
// Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
|
||||
let yBounds = {};
|
||||
const isGeo = ch !== 'MicL';
|
||||
if (isGeo && !isHistogram) {
|
||||
let absMax = 0;
|
||||
for (const v of values) {
|
||||
const a = Math.abs(v);
|
||||
if (a > absMax) absMax = a;
|
||||
}
|
||||
const padded = (absMax || 1) * 1.10;
|
||||
yBounds = { min: -padded, max: padded };
|
||||
} else if (isGeo && isHistogram) {
|
||||
const HIST_GEO_MIN_INS = 0.05;
|
||||
let peak = 0;
|
||||
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
|
||||
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
|
||||
} else if (ch === 'MicL' && micUnit === 'dBL') {
|
||||
const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
|
||||
? chPeak + 5 : 100;
|
||||
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
|
||||
} else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
|
||||
const HIST_MIC_MIN_PSI = 0.001;
|
||||
let peak = 0;
|
||||
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
|
||||
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
|
||||
}
|
||||
|
||||
_charts[ch] = new Chart(canvas, {
|
||||
type: isHistogram ? 'bar' : 'line',
|
||||
data: {
|
||||
labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
|
||||
datasets: isHistogram ? [{
|
||||
data: rV,
|
||||
backgroundColor: _CHANNEL_COLORS[ch],
|
||||
borderWidth: 0,
|
||||
barPercentage: 1.0,
|
||||
categoryPercentage: 1.0,
|
||||
}] : [{
|
||||
data: rV,
|
||||
borderColor: _CHANNEL_COLORS[ch],
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
animation: false, responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'index', intersect: false,
|
||||
callbacks: {
|
||||
title: items => isHistogram
|
||||
? `interval ${items[0].label}`
|
||||
: `t = ${items[0].label} ms`,
|
||||
label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category', display: showX,
|
||||
ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
|
||||
grid: { color: gridColor, drawTicks: showX },
|
||||
},
|
||||
y: {
|
||||
...yBounds,
|
||||
ticks: { color: tickColor, maxTicksLimit: 4 },
|
||||
grid: { color: gridColor },
|
||||
title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: isHistogram ? [] : [{
|
||||
id: 'overlays',
|
||||
afterDraw(chart) {
|
||||
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
|
||||
const zi = rT.findIndex(t => parseFloat(t) >= 0);
|
||||
if (zi >= 0) {
|
||||
const px = x.getPixelForValue(zi);
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
|
||||
ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
|
||||
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
const zy = y.getPixelForValue(0);
|
||||
if (zy >= y.top && zy <= y.bottom) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
|
||||
ctx.setLineDash([2, 2]);
|
||||
ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
|
||||
ctx.restore();
|
||||
ctx.save();
|
||||
ctx.fillStyle = tickColor; ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText('0.0', x.right + 6, zy);
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 txtFilename = src && src.txt_filename;
|
||||
const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`;
|
||||
const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`;
|
||||
|
||||
const downloadButtons = `
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button type="button"
|
||||
onclick="window.toggleEventPdfPreview()"
|
||||
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="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>
|
||||
<span id="event-pdf-toggle-label">Show Event Report PDF</span>
|
||||
</button>
|
||||
<a href="${reportPdfUrl}" download
|
||||
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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Download PDF
|
||||
</a>
|
||||
${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">
|
||||
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="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>
|
||||
Blastware binary
|
||||
<span class="text-xs opacity-60 ml-1">${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">
|
||||
` : ''}
|
||||
${txtFilename ? `
|
||||
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
|
||||
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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
<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>
|
||||
Blastware file unavailable
|
||||
</span>
|
||||
`}
|
||||
Original .TXT report
|
||||
</a>
|
||||
` : ''}
|
||||
<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">
|
||||
@@ -267,6 +626,11 @@
|
||||
Download sidecar JSON
|
||||
</a>
|
||||
</div>
|
||||
<div id="event-pdf-preview" class="hidden mb-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-slate-900">
|
||||
<iframe id="event-pdf-iframe" title="Event Report PDF preview"
|
||||
class="w-full" style="height:80vh; min-height:600px; border:0;"
|
||||
data-pdf-url="${reportPdfUrl}"></iframe>
|
||||
</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>
|
||||
@@ -283,7 +647,9 @@
|
||||
<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 title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that).">
|
||||
<span class="text-gray-500">Time received</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>`;
|
||||
}
|
||||
@@ -345,6 +711,10 @@
|
||||
${_sectionHeader('Peak Particle Velocity')}
|
||||
${_renderPeakValues(s)}
|
||||
|
||||
${_sectionHeader('Waveform')}
|
||||
<div id="event-waveform-status" class="text-xs text-gray-500 dark:text-gray-400 italic mb-2">Loading waveform…</div>
|
||||
<div id="event-waveform-charts" class="space-y-0.5"></div>
|
||||
|
||||
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
||||
${_sectionHeader('Microphone')}
|
||||
${_renderMic(s)}
|
||||
@@ -358,14 +728,43 @@
|
||||
${_renderDeviceMetadata(s)}
|
||||
` : ''}
|
||||
|
||||
${_sectionHeader('Review')}
|
||||
${_renderReview(s, eventId)}
|
||||
|
||||
${_sectionHeader('Source File')}
|
||||
${_renderFileInfo(s, eventId)}
|
||||
`;
|
||||
|
||||
// Waveform load runs after the sidecar content is in the DOM, in
|
||||
// parallel with the mic-unit-pref fetch. Either may complete first.
|
||||
try {
|
||||
const [wfRes, micUnit] = await Promise.all([
|
||||
fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`),
|
||||
_loadMicUnitPref(),
|
||||
]);
|
||||
if (wfRes.status === 404) {
|
||||
document.getElementById('event-waveform-status').textContent =
|
||||
'No waveform data — codec returned 0 valid blocks for this event.';
|
||||
return;
|
||||
}
|
||||
if (!wfRes.ok) {
|
||||
document.getElementById('event-waveform-status').textContent =
|
||||
'Failed to load waveform: HTTP ' + wfRes.status;
|
||||
return;
|
||||
}
|
||||
const wfData = await wfRes.json();
|
||||
document.getElementById('event-waveform-status').textContent = '';
|
||||
_renderWaveformInto('event-waveform-charts', wfData, micUnit);
|
||||
} catch (e) {
|
||||
const st = document.getElementById('event-waveform-status');
|
||||
if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.closeEventDetailModal = function () {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
if (modal) modal.classList.add('hidden');
|
||||
_destroyCharts();
|
||||
};
|
||||
|
||||
window.toggleEventJsonViewer = function () {
|
||||
@@ -376,6 +775,71 @@
|
||||
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
||||
};
|
||||
|
||||
window.toggleEventPdfPreview = function () {
|
||||
const preview = document.getElementById('event-pdf-preview');
|
||||
const iframe = document.getElementById('event-pdf-iframe');
|
||||
const label = document.getElementById('event-pdf-toggle-label');
|
||||
if (!preview || !iframe) return;
|
||||
const isHidden = preview.classList.toggle('hidden');
|
||||
// Lazy-load the PDF: only set the iframe src on first reveal, so
|
||||
// closing the event modal without opening the PDF never spends
|
||||
// bandwidth on it.
|
||||
if (!isHidden && !iframe.src) {
|
||||
iframe.src = iframe.dataset.pdfUrl || '';
|
||||
}
|
||||
if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF';
|
||||
// Scroll the iframe into view on first reveal so the operator
|
||||
// doesn't have to hunt for it after clicking.
|
||||
if (!isHidden) {
|
||||
preview.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
window.saveEventReview = async function (eventId) {
|
||||
const ft = document.getElementById('event-review-ft');
|
||||
const reviewer = document.getElementById('event-review-reviewer');
|
||||
const notes = document.getElementById('event-review-notes');
|
||||
const status = document.getElementById('event-review-status');
|
||||
if (!ft || !reviewer || !notes) return;
|
||||
|
||||
const payload = {
|
||||
review: {
|
||||
false_trigger: ft.checked,
|
||||
reviewer: reviewer.value.trim() || null,
|
||||
notes: notes.value.trim() || null,
|
||||
}
|
||||
};
|
||||
if (status) {
|
||||
status.textContent = 'Saving…';
|
||||
status.className = 'text-xs text-gray-500 dark:text-gray-400';
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text().catch(() => '');
|
||||
throw new Error('HTTP ' + r.status + (t ? ` — ${t.slice(0, 120)}` : ''));
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = 'Saved.';
|
||||
status.className = 'text-xs text-green-600 dark:text-green-400';
|
||||
}
|
||||
// Notify the host page so its event-list FT badge / row state
|
||||
// can refresh. Pages opt in by listening for this event.
|
||||
window.dispatchEvent(new CustomEvent('sfm-event-review-saved', {
|
||||
detail: { eventId, review: payload.review },
|
||||
}));
|
||||
} catch (e) {
|
||||
if (status) {
|
||||
status.textContent = 'Save failed: ' + e.message;
|
||||
status.className = 'text-xs text-red-600 dark:text-red-400';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.copyEventJson = function () {
|
||||
const pre = document.getElementById('event-json-pre');
|
||||
const label = document.getElementById('event-json-copy-label');
|
||||
|
||||
@@ -11,6 +11,10 @@ services:
|
||||
- ENVIRONMENT=production
|
||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||
- SFM_BASE_URL=http://sfm:8200
|
||||
# Display timezone for server logs + any text-rendered timestamps.
|
||||
# DB columns are stored UTC regardless; this only affects what
|
||||
# operators see. Override here for non-US-East deployments.
|
||||
- TZ=America/New_York
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- slmm
|
||||
@@ -56,9 +60,18 @@ services:
|
||||
volumes:
|
||||
- ../seismo-relay/sfm/data:/app/sfm/data
|
||||
- ../seismo-relay/bridges/captures:/app/bridges/captures
|
||||
# The DB + waveform store inside bridges/captures are symlinks
|
||||
# pointing at the prod-snap directory. Mount its host path at
|
||||
# the same absolute path inside the container so the symlinks
|
||||
# resolve. Needed for SFM to query the events DB.
|
||||
- ../seismo-relay-prod-snap:/home/serversdown/seismo-relay-prod-snap
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- PORT=8200
|
||||
# Display timezone — affects server log timestamps, the PDF
|
||||
# report renderer's UTC→local conversions, and matplotlib's
|
||||
# datetime axes. DB columns (created_at etc.) are always UTC.
|
||||
- TZ=America/New_York
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||
|
||||
@@ -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 %}
|
||||
@@ -275,7 +275,13 @@ async function _fetchTypeahead(input, fieldKind) {
|
||||
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
|
||||
@@ -291,16 +297,24 @@ async function _fetchTypeahead(input, fieldKind) {
|
||||
}
|
||||
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="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})"
|
||||
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="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})"
|
||||
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>`;
|
||||
@@ -308,6 +322,19 @@ async function _fetchTypeahead(input, fieldKind) {
|
||||
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}"]`);
|
||||
|
||||
@@ -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,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,370 @@
|
||||
{% 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 cursor-pointer"
|
||||
onclick="showEventDetail('${_esc(ev.id)}')">
|
||||
<td class="px-3 py-2" onclick="event.stopPropagation()"><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.
|
||||
|
||||
// Refresh the events table when the modal's review form saves — keeps
|
||||
// the FT badge in sync without a full page reload.
|
||||
window.addEventListener('sfm-event-review-saved', () => {
|
||||
if (_events.length) loadEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
{% endblock %}
|
||||
+13
-8
@@ -150,6 +150,7 @@
|
||||
(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'
|
||||
@@ -210,7 +211,11 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation (Mobile Only) -->
|
||||
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
||||
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">
|
||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||
@@ -225,18 +230,18 @@
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</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">
|
||||
<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>
|
||||
<span>Devices</span>
|
||||
<span>Deploy</span>
|
||||
</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">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
<span>Events</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -10,17 +10,75 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<div class="mb-8 flex justify-between items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<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 class="flex items-center gap-3">
|
||||
<a href="/deploy"
|
||||
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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div hx-get="/api/status-snapshot"
|
||||
hx-trigger="load, every 10s"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -10,8 +10,8 @@ Usage:
|
||||
#}
|
||||
<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">
|
||||
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-5xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[92vh] 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 z-10">
|
||||
<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">
|
||||
@@ -23,3 +23,7 @@ Usage:
|
||||
<div id="event-detail-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
{# Chart.js — pinned to v4.4.1 to match the SFM webapp's reference impl
|
||||
(v4 chart API; differs from v3). Loaded once globally; safe if other
|
||||
pages on the same template tree also load it. #}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
|
||||
@@ -1,64 +1,302 @@
|
||||
<!-- Project Locations List -->
|
||||
{% 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>
|
||||
</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>
|
||||
<!-- Project Locations List — Active + Removed sections.
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.assignment %}
|
||||
<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">
|
||||
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>
|
||||
Card layout:
|
||||
[drag handle] [location info] [unit pill] [⋮ menu]
|
||||
(name link, description, address, sessions/events, coords)
|
||||
|
||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||
<span>Sessions: {{ item.session_count }}</span>
|
||||
{% if item.assignment and item.assigned_unit %}
|
||||
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
||||
{% else %}
|
||||
<span>No active assignment</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
Active cards are draggable to reorder. Drop reorders the DOM
|
||||
immediately and posts the new order to /api/projects/{p}/locations/reorder.
|
||||
|
||||
Removed cards are NOT reorderable (their order is historical) but
|
||||
show a Restore button.
|
||||
|
||||
The three-dot menu replaces the inline Unassign/Edit/Remove/Delete
|
||||
pill buttons. Click ⋮ to open; click outside closes.
|
||||
-->
|
||||
|
||||
{% if not active_locations and not removed_locations %}
|
||||
<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">
|
||||
<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>
|
||||
<p>No locations added yet</p>
|
||||
</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 %}
|
||||
|
||||
{# ─── 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>
|
||||
@@ -78,22 +78,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
||||
{% if upcoming_actions %}
|
||||
<div class="space-y-3">
|
||||
{% for action in upcoming_actions %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
||||
<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>
|
||||
{# Location map — uses the reusable partial that fetches from
|
||||
/api/projects/{p}/locations-json. Same render is reused on the
|
||||
deeper Vibration tab so both surfaces stay in sync. #}
|
||||
{% with project_id=project.id %}
|
||||
{% include 'partials/projects/location_map.html' %}
|
||||
{% endwith %}
|
||||
</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 %}
|
||||
|
||||
@@ -87,9 +87,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Modal -->
|
||||
<!-- Merge Modal —
|
||||
min-h on the body ensures the typeahead dropdown has room to render
|
||||
below the input without forcing the operator to scroll inside the
|
||||
modal. overflow-visible on the body lets the dropdown extend
|
||||
beyond the body's natural height when needed. -->
|
||||
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col"
|
||||
style="min-height: 480px;">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
@@ -104,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1 min-h-[320px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Target project
|
||||
</label>
|
||||
@@ -202,6 +207,10 @@ async function _mergeFetchTargets() {
|
||||
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>'
|
||||
@@ -212,8 +221,10 @@ async function _mergeFetchTargets() {
|
||||
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="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
|
||||
onclick="_mergePickFromButton(this)"
|
||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
||||
${metaLine}
|
||||
@@ -222,6 +233,13 @@ async function _mergeFetchTargets() {
|
||||
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;
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
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>
|
||||
|
||||
+169
-15
@@ -101,22 +101,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
||||
<button onclick="openLocationModal('vibration')"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add Location
|
||||
</button>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 lg:col-span-2">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
||||
<button onclick="openLocationModal('vibration')"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
<div id="vibration-locations"
|
||||
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vibration-locations"
|
||||
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||
|
||||
{# Reusable location map — fetches from /locations-json
|
||||
on its own. Hovering any of the location cards on the
|
||||
left highlights the matching pin on this map. #}
|
||||
<div>
|
||||
{% with project_id=project_id, location_type='vibration', map_height='450px' %}
|
||||
{% include 'partials/projects/location_map.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -778,6 +789,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Location Confirmation Modal —
|
||||
Soft-removal: preserves historical events, closes active assignments,
|
||||
cancels pending scheduled actions. Distinct from Delete (which is
|
||||
permanent and only allowed when there's no history). -->
|
||||
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 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 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white">…</span> as no longer actively monitored.
|
||||
</p>
|
||||
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
|
||||
<li>Closes any active unit assignment at this location</li>
|
||||
<li>Cancels pending scheduled actions at this location</li>
|
||||
<li>Historical events stay attributed (visible in reports + event lists)</li>
|
||||
<li>Can be restored later if needed</li>
|
||||
</ul>
|
||||
|
||||
<input type="hidden" id="remove-location-id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
|
||||
<input type="datetime-local" id="remove-location-effective"
|
||||
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
|
||||
<input type="text" id="remove-location-reason" maxlength="200"
|
||||
placeholder="e.g. client dropped from scope"
|
||||
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
</div>
|
||||
|
||||
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="closeRemoveLocationModal()"
|
||||
class="px-4 py-1.5 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 onclick="confirmRemoveLocation()"
|
||||
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Project Confirmation Modal -->
|
||||
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 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 p-6">
|
||||
@@ -1213,6 +1279,94 @@ async function deleteLocation(locationId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remove / Restore location ────────────────────────────────────────
|
||||
// Soft-removal: marks a location as no longer actively monitored without
|
||||
// destroying it. Historical events stay attributed; active assignments
|
||||
// are auto-closed and pending scheduled actions are auto-cancelled.
|
||||
|
||||
function openRemoveLocationModal(locationId, locationName) {
|
||||
document.getElementById('remove-location-id').value = locationId;
|
||||
document.getElementById('remove-location-name').textContent = locationName;
|
||||
document.getElementById('remove-location-reason').value = '';
|
||||
// Default effective_date to "now" in local datetime-input format.
|
||||
const now = new Date();
|
||||
const tzOffsetMin = now.getTimezoneOffset();
|
||||
const local = new Date(now.getTime() - tzOffsetMin * 60000);
|
||||
document.getElementById('remove-location-effective').value =
|
||||
local.toISOString().slice(0, 16);
|
||||
document.getElementById('remove-location-error').classList.add('hidden');
|
||||
document.getElementById('remove-location-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRemoveLocationModal() {
|
||||
document.getElementById('remove-location-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function confirmRemoveLocation() {
|
||||
const locationId = document.getElementById('remove-location-id').value;
|
||||
const reason = document.getElementById('remove-location-reason').value.trim();
|
||||
const effective = document.getElementById('remove-location-effective').value;
|
||||
const errBox = document.getElementById('remove-location-error');
|
||||
errBox.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/locations/${locationId}/remove`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason: reason || null,
|
||||
effective_date: effective || null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to remove location');
|
||||
}
|
||||
const result = await response.json();
|
||||
closeRemoveLocationModal();
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
// Lightweight feedback — the UI refresh already shows the location
|
||||
// moving to the Removed section, but a toast confirms the cascade.
|
||||
if (typeof showToast === 'function') {
|
||||
const bits = [];
|
||||
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
|
||||
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
|
||||
const tail = bits.length ? ` (${bits.join(', ')})` : '';
|
||||
showToast(`Location removed${tail}`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
errBox.textContent = err.message || 'Failed to remove location.';
|
||||
errBox.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreLocation(locationId, locationName) {
|
||||
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/locations/${locationId}/restore`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to restore location');
|
||||
}
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`"${locationName}" restored to active`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to restore location.');
|
||||
}
|
||||
}
|
||||
|
||||
// Assign modal functions
|
||||
function openAssignModal(locationId, locationType) {
|
||||
const safeType = locationType || 'sound';
|
||||
|
||||
+35
-1
@@ -122,6 +122,21 @@
|
||||
How often the dashboard should refresh automatically
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event-Report Mic Units -->
|
||||
<div>
|
||||
<label for="mic-unit-pref" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Event Report — Mic Channel Units
|
||||
</label>
|
||||
<select id="mic-unit-pref"
|
||||
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="psi" selected>psi — raw pressure (matches PDF report)</option>
|
||||
<option value="dBL">dB(L) — sound pressure level</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||
@@ -589,6 +604,20 @@
|
||||
</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. #}
|
||||
@@ -757,6 +786,9 @@ async function loadPreferences() {
|
||||
// Load auto-refresh interval
|
||||
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
|
||||
|
||||
// Load event-report mic units
|
||||
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'psi';
|
||||
|
||||
// Load status thresholds
|
||||
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
||||
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
|
||||
@@ -774,6 +806,7 @@ async function saveGeneralSettings() {
|
||||
const timezone = document.getElementById('timezone-select').value;
|
||||
const theme = document.querySelector('input[name="theme"]:checked').value;
|
||||
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
|
||||
const micUnitPref = document.getElementById('mic-unit-pref').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/preferences', {
|
||||
@@ -782,7 +815,8 @@ async function saveGeneralSettings() {
|
||||
body: JSON.stringify({
|
||||
timezone,
|
||||
theme,
|
||||
auto_refresh_interval: autoRefreshInterval
|
||||
auto_refresh_interval: autoRefreshInterval,
|
||||
mic_unit_pref: micUnitPref
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -118,6 +118,13 @@
|
||||
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
<script>
|
||||
// Refresh the events table when the modal's review form saves —
|
||||
// keeps the FT badge in sync without a full page reload.
|
||||
window.addEventListener('sfm-event-review-saved', () => {
|
||||
if (typeof loadEvents === 'function') loadEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sfm-tab {
|
||||
|
||||
@@ -14,6 +14,43 @@
|
||||
<!-- 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">
|
||||
@@ -68,6 +105,24 @@
|
||||
</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">
|
||||
@@ -86,6 +141,24 @@
|
||||
</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">
|
||||
|
||||
+820
-12
@@ -281,17 +281,145 @@
|
||||
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
||||
unit_assignments + unit_history + SFM event overlay) -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex justify-between items-center mb-4 flex-wrap gap-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
||||
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
↻ Refresh
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="openAddAssignmentModal()" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||
+ Add deployment record
|
||||
</button>
|
||||
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gantt chart — visual timeline of all deployments. Click
|
||||
a bar to jump to its row in the list below. -->
|
||||
<div id="deploymentGantt" class="mb-4 hidden">
|
||||
<div class="bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
||||
<svg id="deploymentGanttSvg" class="w-full" style="height: 140px;" preserveAspectRatio="none"></svg>
|
||||
<div id="deploymentGanttLegend" class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-gray-500 dark:text-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="deploymentTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit-assignment modal -->
|
||||
<div id="editAssignmentModal" 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-md max-h-[90vh] 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">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Edit deployment record</h3>
|
||||
<button onclick="closeEditAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">×</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span id="editAssignmentLocation">—</span>
|
||||
<span class="text-xs">·</span>
|
||||
<span id="editAssignmentProject" class="text-xs">—</span>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
|
||||
<input id="editAssignedAt" type="datetime-local" step="60"
|
||||
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">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input id="editAssignedUntil" type="datetime-local" step="60"
|
||||
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">
|
||||
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<input id="editAssignedUntilOpen" type="checkbox" onchange="_toggleEditOpenEnded()">
|
||||
open-ended
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Check "open-ended" to mark this assignment active (no end date).</p>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
||||
<textarea id="editAssignmentNotes" 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"></textarea>
|
||||
</label>
|
||||
<div id="editAssignmentError" 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 items-center justify-between gap-2">
|
||||
<button onclick="deleteAssignmentFromModal()" class="px-3 py-2 text-sm rounded-lg border border-red-300 text-red-700 dark:border-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
Delete
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="closeEditAssignmentModal()" class="px-4 py-2 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">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="editAssignmentSaveBtn" onclick="saveEditAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add-historical-assignment modal -->
|
||||
<div id="addAssignmentModal" 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-md max-h-[90vh] 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">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add deployment record</h3>
|
||||
<button onclick="closeAddAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">×</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Create a deployment record for this unit — usually to backfill a historical window so orphan events get attributed.
|
||||
</p>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</span>
|
||||
<select id="addAssignmentProject" onchange="_addAssignmentProjectChanged()"
|
||||
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">
|
||||
<option value="">Loading…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</span>
|
||||
<select id="addAssignmentLocation"
|
||||
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" disabled>
|
||||
<option value="">Pick a project first</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
|
||||
<input id="addAssignedAt" type="datetime-local" step="60"
|
||||
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">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input id="addAssignedUntil" type="datetime-local" step="60"
|
||||
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">
|
||||
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<input id="addAssignedUntilOpen" type="checkbox" onchange="_toggleAddOpenEnded()">
|
||||
open-ended
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
||||
<textarea id="addAssignmentNotes" rows="2"
|
||||
placeholder="e.g. Backfilled to attribute orphan events 2026-03-16 — 2026-03-25"
|
||||
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"></textarea>
|
||||
</label>
|
||||
<div id="addAssignmentError" 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="closeAddAssignmentModal()" class="px-4 py-2 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">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="addAssignmentSaveBtn" onclick="saveAddAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SFM Events (seismographs only) -->
|
||||
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
@@ -369,6 +497,20 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bulk false-trigger flagging -->
|
||||
<div id="ue-bulk-actions" class="flex flex-wrap items-center gap-2 mb-3 text-sm">
|
||||
<span id="ue-bulk-selected" class="text-gray-600 dark:text-gray-400">0 selected</span>
|
||||
<button id="ue-bulk-flag-ft" onclick="flagSelectedUnitEvents(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 false trigger
|
||||
</button>
|
||||
<button id="ue-bulk-clear-ft" onclick="flagSelectedUnitEvents(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 false trigger
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">For deletion / DB cleanup, use the <a href="/admin/events" class="text-seismo-orange hover:underline">Event DB Manager</a>.</span>
|
||||
</div>
|
||||
|
||||
<!-- Event table -->
|
||||
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
|
||||
@@ -1986,6 +2128,10 @@ loadUnitData().then(() => {
|
||||
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
||||
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
||||
|
||||
// Cache the most recent timeline payload so the merge action can look up
|
||||
// which assignment_ids belong together in a mergeable group.
|
||||
let _dtCurrentTimeline = { entries: [], merge_groups: [] };
|
||||
|
||||
async function loadDeploymentTimeline() {
|
||||
const container = document.getElementById('deploymentTimeline');
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
||||
@@ -1994,12 +2140,58 @@ async function loadDeploymentTimeline() {
|
||||
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
renderDeploymentTimeline(d.entries || [], container);
|
||||
_dtCurrentTimeline = {
|
||||
entries: d.entries || [],
|
||||
merge_groups: d.merge_groups || [],
|
||||
};
|
||||
renderDeploymentTimeline(_dtCurrentTimeline.entries, container, _dtCurrentTimeline.merge_groups);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the merge_group (list of assignment_ids) that this assignment is
|
||||
// part of, or null if it isn't in any mergeable group.
|
||||
function _dtFindMergeGroup(assignmentId) {
|
||||
for (const group of _dtCurrentTimeline.merge_groups || []) {
|
||||
if (group.includes(assignmentId)) return group;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mergeAssignmentGroup(assignmentIds) {
|
||||
if (!Array.isArray(assignmentIds) || assignmentIds.length < 2) return;
|
||||
const msg = `Merge ${assignmentIds.length} consecutive assignment records into one?\n\n`
|
||||
+ `The earliest record is kept and its window extended to span all `
|
||||
+ `of them. The other ${assignmentIds.length - 1} record(s) are deleted.\n\n`
|
||||
+ `Original metadata (notes + ingest source) is preserved. This is `
|
||||
+ `logged to the unit's history as "assignment_merged".`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
try {
|
||||
// All assignments share the same project_id (validated server-side).
|
||||
// Pick the first entry's project_id from the cache.
|
||||
const first = (_dtCurrentTimeline.entries || []).find(e =>
|
||||
e.kind === 'assignment' && assignmentIds.includes(e.assignment_id)
|
||||
);
|
||||
const projectId = first ? first.project_id : null;
|
||||
if (!projectId) throw new Error('Could not resolve project id for this group');
|
||||
|
||||
const r = await fetch(`/api/projects/${projectId}/assignments/merge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assignment_ids: assignmentIds }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
await loadDeploymentTimeline();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to merge assignments.');
|
||||
}
|
||||
}
|
||||
|
||||
function _dtFmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 10);
|
||||
@@ -2044,6 +2236,17 @@ function _dtRenderAssignment(e) {
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
|
||||
// If this assignment belongs to a mergeable group, show a small
|
||||
// indicator badge — the group-level "Merge" action lives in the
|
||||
// banner at the top of the section to avoid N redundant buttons.
|
||||
const mergeGroup = _dtFindMergeGroup(e.assignment_id);
|
||||
const mergeableBadge = mergeGroup
|
||||
? `<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
title="This row is part of a ${mergeGroup.length}-record consecutive group at the same location — see the Merge banner above to combine them.">
|
||||
mergeable
|
||||
</span>`
|
||||
: '';
|
||||
|
||||
const overlay = evCount > 0
|
||||
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
||||
@@ -2056,7 +2259,17 @@ function _dtRenderAssignment(e) {
|
||||
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="flex gap-3">
|
||||
// Edit/delete actions live on the right of the date row. Only shown
|
||||
// for assignment entries with a real assignment_id (synthesized legacy
|
||||
// entries without one are read-only).
|
||||
const actionButtons = e.assignment_id
|
||||
? `<button type="button" onclick='openEditAssignmentModal(${JSON.stringify(e.assignment_id)})'
|
||||
class="text-xs text-gray-500 hover:text-seismo-orange p-1 rounded" title="Edit dates / notes">
|
||||
✏️
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
||||
</div>
|
||||
@@ -2065,7 +2278,11 @@ function _dtRenderAssignment(e) {
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
||||
</div>
|
||||
${activeBadge}
|
||||
<div class="flex items-center gap-2">
|
||||
${mergeableBadge}
|
||||
${activeBadge}
|
||||
${actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1">${locLink}</div>
|
||||
${projLine}
|
||||
@@ -2118,18 +2335,508 @@ function _dtRenderStateChange(e) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDeploymentTimeline(entries, container) {
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
||||
// ── Gantt chart ─────────────────────────────────────────────────────────────
|
||||
// Renders all assignment windows as colored horizontal bars on an SVG
|
||||
// timeline. Click a bar to scroll its detail row into view in the list
|
||||
// below. Color per location, opacity reduced for closed assignments.
|
||||
// "Mergeable" groups get a unifying outline overlay so they're visible at
|
||||
// a glance as one logical deployment.
|
||||
const _ganttColorPalette = [
|
||||
'#f48b1c', '#142a66', '#7d234d', '#0e7490', '#15803d', '#a16207',
|
||||
'#9333ea', '#dc2626', '#0d9488', '#1d4ed8', '#be185d', '#65a30d',
|
||||
];
|
||||
function _ganttColorFor(locId, locColorMap) {
|
||||
if (locColorMap[locId]) return locColorMap[locId];
|
||||
const idx = Object.keys(locColorMap).length % _ganttColorPalette.length;
|
||||
locColorMap[locId] = _ganttColorPalette[idx];
|
||||
return locColorMap[locId];
|
||||
}
|
||||
|
||||
function _ganttParseDate(iso) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso.replace(' ', 'T'));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function _ganttFmtMonth(d) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
}
|
||||
|
||||
function renderDeploymentGantt(entries, mergeGroups) {
|
||||
const wrapper = document.getElementById('deploymentGantt');
|
||||
const svg = document.getElementById('deploymentGanttSvg');
|
||||
const legend = document.getElementById('deploymentGanttLegend');
|
||||
if (!wrapper || !svg) return;
|
||||
|
||||
const assignments = (entries || []).filter(e => e.kind === 'assignment' && e.starts_at);
|
||||
if (assignments.length === 0) {
|
||||
wrapper.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
wrapper.classList.remove('hidden');
|
||||
|
||||
// Compute time domain. Pad the end by a few days when an active
|
||||
// assignment is present so the "active" bar doesn't reach the very
|
||||
// edge of the chart.
|
||||
const now = new Date();
|
||||
let minDate = null, maxDate = null;
|
||||
for (const a of assignments) {
|
||||
const start = _ganttParseDate(a.starts_at);
|
||||
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
||||
if (start && (!minDate || start < minDate)) minDate = start;
|
||||
if (end && (!maxDate || end > maxDate)) maxDate = end;
|
||||
}
|
||||
if (!minDate || !maxDate) { wrapper.classList.add('hidden'); return; }
|
||||
// Tiny padding at both ends (3% of total span).
|
||||
const span = maxDate - minDate;
|
||||
const pad = Math.max(span * 0.03, 24 * 3600 * 1000); // at least 1 day
|
||||
minDate = new Date(minDate.getTime() - pad);
|
||||
maxDate = new Date(maxDate.getTime() + pad);
|
||||
|
||||
// Build a quick "which mergeGroup is this id in?" map.
|
||||
const idToGroup = {};
|
||||
(mergeGroups || []).forEach((g, idx) => g.forEach(id => { idToGroup[id] = idx; }));
|
||||
|
||||
// Compute SVG geometry.
|
||||
const width = Math.max(svg.clientWidth || svg.parentElement.clientWidth || 800, 400);
|
||||
const height = 140;
|
||||
const padLeft = 8;
|
||||
const padRight = 8;
|
||||
const padTop = 32; // room for month labels above the bars
|
||||
const padBottom = 18; // room for assignment-count axis below
|
||||
const usableW = width - padLeft - padRight;
|
||||
const usableH = height - padTop - padBottom;
|
||||
const totalRange = maxDate - minDate;
|
||||
const xFor = (d) => padLeft + (d - minDate) / totalRange * usableW;
|
||||
|
||||
// Choose one-row-per-bar OR stack overlapping bars. Since same-unit
|
||||
// assignments rarely overlap (only via the brief unassign/reassign
|
||||
// race), a single row is usually fine. But just in case, stack with
|
||||
// simple top-down packing.
|
||||
const lanes = []; // each lane = [{x1, x2, ...}, ...]
|
||||
function placeInLane(start, end) {
|
||||
for (let i = 0; i < lanes.length; i++) {
|
||||
const last = lanes[i][lanes[i].length - 1];
|
||||
if (last.x2 + 2 < start) {
|
||||
lanes[i].push({ x1: start, x2: end });
|
||||
return i;
|
||||
}
|
||||
}
|
||||
lanes.push([{ x1: start, x2: end }]);
|
||||
return lanes.length - 1;
|
||||
}
|
||||
const placed = assignments.map(a => {
|
||||
const start = _ganttParseDate(a.starts_at);
|
||||
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
||||
const x1 = xFor(start);
|
||||
const x2 = xFor(end);
|
||||
const lane = placeInLane(x1, x2);
|
||||
return { a, x1, x2, lane };
|
||||
});
|
||||
const laneCount = Math.max(lanes.length, 1);
|
||||
const barH = Math.max(14, Math.min(28, Math.floor(usableH / laneCount) - 4));
|
||||
const laneSpacing = barH + 4;
|
||||
|
||||
// Month gridlines + labels. Tick on the 1st of each month inside the
|
||||
// domain. If span > 24mo, tick every 3 months instead.
|
||||
const months = [];
|
||||
let monthCursor = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
|
||||
const tickEveryMonths = (totalRange > 24 * 30 * 86400 * 1000) ? 3 : 1;
|
||||
while (monthCursor <= maxDate) {
|
||||
if (monthCursor >= minDate) months.push(new Date(monthCursor));
|
||||
monthCursor.setMonth(monthCursor.getMonth() + tickEveryMonths);
|
||||
}
|
||||
|
||||
// Build the SVG string.
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||||
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
const todayColor = '#f48b1c';
|
||||
|
||||
const locColorMap = {};
|
||||
const usedLocs = {};
|
||||
|
||||
let parts = [];
|
||||
// Month gridlines.
|
||||
months.forEach(m => {
|
||||
const x = xFor(m);
|
||||
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||
parts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${_ganttFmtMonth(m)}</text>`);
|
||||
});
|
||||
// Today marker.
|
||||
if (now >= minDate && now <= maxDate) {
|
||||
const x = xFor(now);
|
||||
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.8"/>`);
|
||||
parts.push(`<text x="${x + 3}" y="${height - padBottom + 12}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`);
|
||||
}
|
||||
|
||||
// Bars.
|
||||
placed.forEach(p => {
|
||||
const a = p.a;
|
||||
const color = _ganttColorFor(a.location_id || '_', locColorMap);
|
||||
usedLocs[a.location_name || '(no location)'] = color;
|
||||
const y = padTop + p.lane * laneSpacing;
|
||||
const opacity = a.is_active ? 1.0 : 0.85;
|
||||
const stroke = (a.source === 'metadata_backfill') ? '#3b82f6' : 'none';
|
||||
const strokeWidth = (a.source === 'metadata_backfill') ? 2 : 0;
|
||||
|
||||
const barW = Math.max(p.x2 - p.x1, 3);
|
||||
const tipDates = `${(a.starts_at || '').slice(0,10)} → ${a.is_active ? 'active' : (a.ends_at || '').slice(0,10)}`;
|
||||
const tip = `${(a.location_name || '?').replace(/"/g, '"')} (${tipDates})${a.event_overlay && a.event_overlay.event_count ? ' • ' + a.event_overlay.event_count + ' events' : ''}`;
|
||||
|
||||
parts.push(`<g style="cursor: pointer;" onclick="_ganttScrollTo('${a.assignment_id}')">
|
||||
<title>${tip}</title>
|
||||
<rect x="${p.x1}" y="${y}" width="${barW}" height="${barH}" rx="3"
|
||||
fill="${color}" opacity="${opacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>
|
||||
${a.is_active ? `<circle cx="${p.x2 - 4}" cy="${y + barH / 2}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
|
||||
</g>`);
|
||||
|
||||
// Mergeable highlight — thin dashed underline below the bar.
|
||||
if (idToGroup[a.assignment_id] !== undefined) {
|
||||
const uy = y + barH + 1;
|
||||
parts.push(`<line x1="${p.x1}" y1="${uy}" x2="${p.x2}" y2="${uy}" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.7"/>`);
|
||||
}
|
||||
});
|
||||
|
||||
svg.innerHTML = parts.join('');
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
// Build legend (one swatch per distinct location).
|
||||
const legendItems = Object.entries(usedLocs).map(([name, color]) =>
|
||||
`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded" style="background:${color}"></span>${_dtEsc(name)}</span>`
|
||||
);
|
||||
if (mergeGroups && mergeGroups.length > 0) {
|
||||
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 border-b-2 border-dashed border-blue-500"></span>mergeable group</span>`);
|
||||
}
|
||||
if (placed.some(p => p.a.source === 'metadata_backfill')) {
|
||||
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded border-2 border-blue-500"></span>auto-backfilled</span>`);
|
||||
}
|
||||
legend.innerHTML = legendItems.join('');
|
||||
}
|
||||
|
||||
// Click-on-bar handler. Just scroll the matching list row into view +
|
||||
// briefly flash it so the eye finds it.
|
||||
function _ganttScrollTo(assignmentId) {
|
||||
const target = document.querySelector(`[data-assignment-row="${assignmentId}"]`);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add('ring-2', 'ring-seismo-orange');
|
||||
setTimeout(() => target.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||
}
|
||||
|
||||
function renderDeploymentTimeline(entries, container, mergeGroups) {
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
||||
// Hide the Gantt block too.
|
||||
const g = document.getElementById('deploymentGantt');
|
||||
if (g) g.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Render the Gantt chart first (above the list).
|
||||
renderDeploymentGantt(entries, mergeGroups);
|
||||
|
||||
// Build the mergeable-groups banner. Each group offers one "Merge into
|
||||
// one" button. Skipped when no groups exist.
|
||||
let bannerHtml = '';
|
||||
if (mergeGroups && mergeGroups.length > 0) {
|
||||
const rows = mergeGroups.map(group => {
|
||||
// Look up the entries to describe what we're merging.
|
||||
const groupEntries = (entries || []).filter(e =>
|
||||
e.kind === 'assignment' && group.includes(e.assignment_id)
|
||||
);
|
||||
if (groupEntries.length === 0) return '';
|
||||
const locName = groupEntries[0].location_name || 'unnamed location';
|
||||
const earliest = groupEntries.map(e => e.starts_at).filter(Boolean).sort()[0] || '';
|
||||
const latest = groupEntries.map(e => e.ends_at).filter(Boolean).sort().reverse()[0] || 'present';
|
||||
const idsJson = JSON.stringify(group).replace(/"/g, '"');
|
||||
return `<div class="flex items-center justify-between gap-3 py-1.5">
|
||||
<div class="text-sm text-blue-900 dark:text-blue-200 min-w-0 flex-1">
|
||||
<strong>${group.length} consecutive records</strong> at <strong>${_dtEsc(locName)}</strong>
|
||||
<span class="text-xs text-blue-700 dark:text-blue-300 ml-2">${_dtFmtDate(earliest)} → ${_dtFmtDate(latest)}</span>
|
||||
</div>
|
||||
<button onclick='mergeAssignmentGroup(${JSON.stringify(group)})'
|
||||
class="px-3 py-1 text-xs rounded-full bg-blue-600 hover:bg-blue-700 text-white font-medium whitespace-nowrap">
|
||||
Merge into one
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
bannerHtml = `<div class="mb-4 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 flex-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 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div class="text-xs text-blue-800 dark:text-blue-300">
|
||||
Consecutive deployments at the same location detected. Combine them into a single record to clean up the view (notes + ingest sources are preserved).
|
||||
</div>
|
||||
</div>
|
||||
${rows}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const html = entries.map(e => {
|
||||
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
||||
if (e.kind === 'gap') return _dtRenderGap(e);
|
||||
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
||||
return '';
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
|
||||
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
|
||||
}
|
||||
|
||||
// ── Deployment-timeline editor ──────────────────────────────────────────────
|
||||
// Edit / delete an existing UnitAssignment row, or create a historical one
|
||||
// (backfill orphan-event windows). All three operations hit endpoints that
|
||||
// already exist on the project-locations router; after a save we just
|
||||
// reload the timeline + events.
|
||||
|
||||
let _editAssignmentCtx = null; // { assignment_id, project_id, location_name, project_name }
|
||||
|
||||
function _iso_to_local_input(iso) {
|
||||
// The PATCH/POST endpoints accept "YYYY-MM-DDTHH:MM[:SS]" strings
|
||||
// (datetime.fromisoformat). datetime-local inputs emit the same shape
|
||||
// without timezone. We just strip the trailing "Z" if present and
|
||||
// truncate to minutes.
|
||||
if (!iso) return '';
|
||||
let s = String(iso).replace('Z', '');
|
||||
// Slice down to YYYY-MM-DDTHH:MM (16 chars).
|
||||
return s.slice(0, 16);
|
||||
}
|
||||
|
||||
function openEditAssignmentModal(assignmentId) {
|
||||
const entry = (_dtCurrentTimeline.entries || []).find(
|
||||
e => e.kind === 'assignment' && e.assignment_id === assignmentId
|
||||
);
|
||||
if (!entry) {
|
||||
alert('Could not find this assignment in the loaded timeline.');
|
||||
return;
|
||||
}
|
||||
_editAssignmentCtx = {
|
||||
assignment_id: entry.assignment_id,
|
||||
project_id: entry.project_id,
|
||||
location_name: entry.location_name || 'unnamed location',
|
||||
project_name: entry.project_name || '',
|
||||
};
|
||||
|
||||
document.getElementById('editAssignmentLocation').textContent = _editAssignmentCtx.location_name;
|
||||
document.getElementById('editAssignmentProject').textContent = _editAssignmentCtx.project_name;
|
||||
|
||||
document.getElementById('editAssignedAt').value = _iso_to_local_input(entry.starts_at);
|
||||
const endsAtInput = document.getElementById('editAssignedUntil');
|
||||
const openCheckbox = document.getElementById('editAssignedUntilOpen');
|
||||
if (entry.is_active) {
|
||||
endsAtInput.value = '';
|
||||
endsAtInput.disabled = true;
|
||||
openCheckbox.checked = true;
|
||||
} else {
|
||||
endsAtInput.value = _iso_to_local_input(entry.ends_at);
|
||||
endsAtInput.disabled = false;
|
||||
openCheckbox.checked = false;
|
||||
}
|
||||
document.getElementById('editAssignmentNotes').value = entry.notes || '';
|
||||
document.getElementById('editAssignmentError').classList.add('hidden');
|
||||
document.getElementById('editAssignmentModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeEditAssignmentModal() {
|
||||
document.getElementById('editAssignmentModal').classList.add('hidden');
|
||||
_editAssignmentCtx = null;
|
||||
}
|
||||
|
||||
function _toggleEditOpenEnded() {
|
||||
const open = document.getElementById('editAssignedUntilOpen').checked;
|
||||
const input = document.getElementById('editAssignedUntil');
|
||||
input.disabled = open;
|
||||
if (open) input.value = '';
|
||||
}
|
||||
|
||||
async function saveEditAssignment() {
|
||||
if (!_editAssignmentCtx) return;
|
||||
const err = document.getElementById('editAssignmentError');
|
||||
err.classList.add('hidden');
|
||||
|
||||
const assignedAt = document.getElementById('editAssignedAt').value;
|
||||
if (!assignedAt) {
|
||||
err.textContent = 'Assigned-at is required.';
|
||||
err.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const open = document.getElementById('editAssignedUntilOpen').checked;
|
||||
const assignedUntil = open ? null : (document.getElementById('editAssignedUntil').value || null);
|
||||
const notes = document.getElementById('editAssignmentNotes').value;
|
||||
|
||||
const btn = document.getElementById('editAssignmentSaveBtn');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
|
||||
try {
|
||||
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
|
||||
const r = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
assigned_at: assignedAt,
|
||||
assigned_until: assignedUntil,
|
||||
notes: notes,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
closeEditAssignmentModal();
|
||||
await loadDeploymentTimeline();
|
||||
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
|
||||
loadUnitEvents();
|
||||
}
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Save';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAssignmentFromModal() {
|
||||
if (!_editAssignmentCtx) return;
|
||||
if (!confirm('Hard-delete this deployment record?\n\nThe assignment row and its history audit entry are removed. Use this only for misclicks — to end a real deployment, edit the "assigned until" date instead.')) return;
|
||||
const err = document.getElementById('editAssignmentError');
|
||||
err.classList.add('hidden');
|
||||
try {
|
||||
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
|
||||
const r = await fetch(url, { method: 'DELETE' });
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
closeEditAssignmentModal();
|
||||
await loadDeploymentTimeline();
|
||||
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
|
||||
loadUnitEvents();
|
||||
}
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add-historical-assignment modal ─────────────────────────────────────────
|
||||
let _addAssignmentProjectsCache = null;
|
||||
|
||||
async function openAddAssignmentModal() {
|
||||
if (!currentUnit) return;
|
||||
document.getElementById('addAssignmentError').classList.add('hidden');
|
||||
document.getElementById('addAssignedAt').value = '';
|
||||
document.getElementById('addAssignedUntil').value = '';
|
||||
document.getElementById('addAssignedUntilOpen').checked = false;
|
||||
document.getElementById('addAssignedUntil').disabled = false;
|
||||
document.getElementById('addAssignmentNotes').value = '';
|
||||
|
||||
const locSel = document.getElementById('addAssignmentLocation');
|
||||
locSel.disabled = true;
|
||||
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
||||
|
||||
const projSel = document.getElementById('addAssignmentProject');
|
||||
projSel.innerHTML = '<option value="">Loading…</option>';
|
||||
|
||||
document.getElementById('addAssignmentModal').classList.remove('hidden');
|
||||
|
||||
try {
|
||||
if (!_addAssignmentProjectsCache) {
|
||||
const r = await fetch('/api/projects/search-json?limit=50');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
_addAssignmentProjectsCache = await r.json();
|
||||
}
|
||||
projSel.innerHTML = '<option value="">— pick project —</option>'
|
||||
+ _addAssignmentProjectsCache.map(p =>
|
||||
`<option value="${_dtEsc(p.id)}">${_dtEsc(p.name)}${p.project_number ? ' (' + _dtEsc(p.project_number) + ')' : ''}</option>`
|
||||
).join('');
|
||||
} catch (e) {
|
||||
projSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAddAssignmentModal() {
|
||||
document.getElementById('addAssignmentModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function _toggleAddOpenEnded() {
|
||||
const open = document.getElementById('addAssignedUntilOpen').checked;
|
||||
const input = document.getElementById('addAssignedUntil');
|
||||
input.disabled = open;
|
||||
if (open) input.value = '';
|
||||
}
|
||||
|
||||
async function _addAssignmentProjectChanged() {
|
||||
const projectId = document.getElementById('addAssignmentProject').value;
|
||||
const locSel = document.getElementById('addAssignmentLocation');
|
||||
if (!projectId) {
|
||||
locSel.disabled = true;
|
||||
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
||||
return;
|
||||
}
|
||||
locSel.disabled = true;
|
||||
locSel.innerHTML = '<option value="">Loading locations…</option>';
|
||||
// Match the device type to the location_type filter.
|
||||
const wantType = (currentUnit && currentUnit.device_type === 'slm') ? 'sound' : 'vibration';
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/locations-json?location_type=${wantType}`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const locs = await r.json();
|
||||
if (!locs.length) {
|
||||
locSel.innerHTML = '<option value="">No matching locations in this project</option>';
|
||||
return;
|
||||
}
|
||||
locSel.disabled = false;
|
||||
locSel.innerHTML = '<option value="">— pick location —</option>'
|
||||
+ locs.map(l => `<option value="${_dtEsc(l.id)}">${_dtEsc(l.name)}</option>`).join('');
|
||||
} catch (e) {
|
||||
locSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAddAssignment() {
|
||||
if (!currentUnit) return;
|
||||
const err = document.getElementById('addAssignmentError');
|
||||
err.classList.add('hidden');
|
||||
|
||||
const projectId = document.getElementById('addAssignmentProject').value;
|
||||
const locationId = document.getElementById('addAssignmentLocation').value;
|
||||
const assignedAt = document.getElementById('addAssignedAt').value;
|
||||
const open = document.getElementById('addAssignedUntilOpen').checked;
|
||||
const assignedUntil = open ? '' : document.getElementById('addAssignedUntil').value;
|
||||
const notes = document.getElementById('addAssignmentNotes').value;
|
||||
|
||||
if (!projectId) { err.textContent = 'Pick a project.'; err.classList.remove('hidden'); return; }
|
||||
if (!locationId) { err.textContent = 'Pick a location.'; err.classList.remove('hidden'); return; }
|
||||
if (!assignedAt) { err.textContent = 'Assigned-at is required.'; err.classList.remove('hidden'); return; }
|
||||
|
||||
const btn = document.getElementById('addAssignmentSaveBtn');
|
||||
btn.disabled = true; btn.textContent = 'Creating…';
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('unit_id', currentUnit.id);
|
||||
fd.append('assigned_at', assignedAt);
|
||||
if (assignedUntil) fd.append('assigned_until', assignedUntil);
|
||||
if (notes) fd.append('notes', notes);
|
||||
|
||||
const url = `/api/projects/${encodeURIComponent(projectId)}/locations/${encodeURIComponent(locationId)}/assign`;
|
||||
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);
|
||||
}
|
||||
closeAddAssignmentModal();
|
||||
await loadDeploymentTimeline();
|
||||
if (typeof loadUnitEvents === 'function' && currentUnit.device_type === 'seismograph') {
|
||||
loadUnitEvents();
|
||||
}
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Create';
|
||||
}
|
||||
}
|
||||
|
||||
// ── SFM Events section ──────────────────────────────────────────────────────
|
||||
@@ -2286,12 +2993,17 @@ function _ueAttrCell(ev) {
|
||||
if (a) {
|
||||
const projLabel = _ueEsc(a.project_name || '—');
|
||||
const locLabel = _ueEsc(a.location_name || '—');
|
||||
// If the attributed location has since been soft-removed, badge
|
||||
// it so operators see at a glance this is historical attribution.
|
||||
const removedBadge = a.location_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" title="Location no longer actively monitored">removed</span>'
|
||||
: '';
|
||||
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="text-seismo-orange hover:text-seismo-navy"
|
||||
title="${projLabel} → ${locLabel}">
|
||||
📍 ${locLabel}
|
||||
</a>
|
||||
</a>${removedBadge}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||
}
|
||||
const n = ev.nearest_assignment;
|
||||
@@ -2338,7 +3050,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
||||
: '';
|
||||
|
||||
const checked = _ueSelectedEventIds.has(ev.id) ? 'checked' : '';
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}" onclick="showEventDetail('${_dtEsc(ev.id)}')">
|
||||
<td class="px-3 py-2.5 text-sm" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="ue-row-check rounded border-gray-300 dark:border-gray-600"
|
||||
data-event-id="${_dtEsc(ev.id)}" ${checked}
|
||||
onchange="onUnitEventRowCheck(this)">
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
||||
@@ -2354,6 +3072,11 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th class="px-3 py-2">
|
||||
<input type="checkbox" id="ue-check-all"
|
||||
class="rounded border-gray-300 dark:border-gray-600"
|
||||
onchange="toggleAllUnitEventRows(this.checked)">
|
||||
</th>
|
||||
${_ueSortableTh('Timestamp', 'timestamp')}
|
||||
${_ueSortableTh('Tran', 'tran_ppv')}
|
||||
${_ueSortableTh('Vert', 'vert_ppv')}
|
||||
@@ -2365,6 +3088,85 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
</table>`;
|
||||
_ueRefreshBulkButton();
|
||||
}
|
||||
|
||||
// ===== Bulk false-trigger flagging =====
|
||||
// Selection is keyed by event ID and persists across table re-renders, so
|
||||
// users can paginate / re-sort without losing their selection.
|
||||
const _ueSelectedEventIds = new Set();
|
||||
|
||||
function _ueRefreshBulkButton() {
|
||||
const n = _ueSelectedEventIds.size;
|
||||
const lbl = document.getElementById('ue-bulk-selected');
|
||||
const flag = document.getElementById('ue-bulk-flag-ft');
|
||||
const clr = document.getElementById('ue-bulk-clear-ft');
|
||||
if (lbl) lbl.textContent = `${n} selected`;
|
||||
if (flag) flag.disabled = (n === 0);
|
||||
if (clr) clr.disabled = (n === 0);
|
||||
}
|
||||
|
||||
function onUnitEventRowCheck(input) {
|
||||
const id = input.getAttribute('data-event-id');
|
||||
if (input.checked) {
|
||||
_ueSelectedEventIds.add(id);
|
||||
} else {
|
||||
_ueSelectedEventIds.delete(id);
|
||||
// If we just unchecked a row, the master "all" checkbox shouldn't stay checked.
|
||||
const master = document.getElementById('ue-check-all');
|
||||
if (master) master.checked = false;
|
||||
}
|
||||
_ueRefreshBulkButton();
|
||||
}
|
||||
|
||||
function toggleAllUnitEventRows(checked) {
|
||||
document.querySelectorAll('.ue-row-check').forEach(cb => {
|
||||
const id = cb.getAttribute('data-event-id');
|
||||
cb.checked = checked;
|
||||
if (checked) _ueSelectedEventIds.add(id);
|
||||
else _ueSelectedEventIds.delete(id);
|
||||
});
|
||||
_ueRefreshBulkButton();
|
||||
}
|
||||
|
||||
async function flagSelectedUnitEvents(value) {
|
||||
// value = true → flag as false trigger
|
||||
// value = false → clear false-trigger flag
|
||||
if (_ueSelectedEventIds.size === 0) return;
|
||||
const ids = Array.from(_ueSelectedEventIds);
|
||||
const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on';
|
||||
if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// SFM exposes single-row PATCH only. Fan out concurrently with a
|
||||
// modest cap so we don't open hundreds of sockets at once.
|
||||
const concurrency = 8;
|
||||
let ok = 0, failed = 0;
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < ids.length) {
|
||||
const i = cursor++;
|
||||
const id = ids[i];
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/sfm/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`,
|
||||
{ method: 'PATCH' }
|
||||
);
|
||||
if (resp.ok) ok++;
|
||||
else failed++;
|
||||
} catch (_) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
|
||||
if (failed) {
|
||||
alert(`${ok} updated, ${failed} failed. Refreshing table.`);
|
||||
}
|
||||
_ueSelectedEventIds.clear();
|
||||
loadUnitEvents();
|
||||
}
|
||||
|
||||
// ===== Pair Device Modal Functions =====
|
||||
@@ -2918,5 +3720,11 @@ function showToast(message, type = 'info') {
|
||||
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
<script>
|
||||
// Refresh the unit's events table when the modal's review form saves.
|
||||
window.addEventListener('sfm-event-review-saved', () => {
|
||||
if (typeof loadUnitEvents === 'function') loadUnitEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) {
|
||||
<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>`;
|
||||
@@ -608,6 +616,33 @@ function openAssignmentEditModal(encodedJson) {
|
||||
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');
|
||||
}
|
||||
@@ -957,4 +992,10 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
||||
{# 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>
|
||||
<script>
|
||||
// Refresh the location's events table when the modal's review form saves.
|
||||
window.addEventListener('sfm-event-review-saved', () => {
|
||||
if (typeof loadLocationEvents === 'function') loadLocationEvents();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user