Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4fd1c943d |
-165
@@ -5,171 +5,6 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [0.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
|
## [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.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.13.0
|
# Terra-View v0.11.0
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -9,20 +9,15 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
|
|||||||
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
||||||
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
||||||
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
||||||
- **Field-Deployment Workflow**: One-photo mobile capture at `/deploy` → desk-side classification at `/tools/pending-deployments` → automatic UnitAssignment creation with EXIF GPS
|
|
||||||
- **Unit Swap Wizard** (`/tools/unit-swap`): mobile-first 4-step flow for swapping a vibration unit (and optionally its modem) at a monitoring location. Surfaces benched-fleet candidates as eligible incoming units; cleans up stale modem back-references on swap
|
|
||||||
- **Editable Deployment Timeline** on every unit detail page: inline edit / delete each assignment, plus an "Add deployment record" button for backfilling historical windows. Frees-up previously-orphaned events when their timestamp now falls inside an assignment
|
|
||||||
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
||||||
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
||||||
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
||||||
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
||||||
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
||||||
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
||||||
- **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
|
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
||||||
- **Deployment-History Calendar + Gantt** (`/tools/deployment-history`): fleet-wide 12-month calendar with side-panel day drill-down, plus "Gantt by Project" / "Gantt by Unit" tabs
|
|
||||||
- **Photo Management**: Upload and view photos for each unit
|
- **Photo Management**: Upload and view photos for each unit
|
||||||
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab)
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
||||||
- **Timezone-Aware Timeline**: deployment assignments display and edit in the user's configured local timezone; UTC stays canonical on disk
|
|
||||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||||
- **Database Management**: Comprehensive backup and restore system
|
- **Database Management**: Comprehensive backup and restore system
|
||||||
- **Manual Snapshots**: Create on-demand backups with descriptions
|
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||||
|
|||||||
+1
-31
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.13.0"
|
VERSION = "0.11.0"
|
||||||
if ENVIRONMENT == "development":
|
if ENVIRONMENT == "development":
|
||||||
_build = os.getenv("BUILD_NUMBER", "0")
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
if _build and _build != "0":
|
if _build and _build != "0":
|
||||||
@@ -109,12 +109,6 @@ app.include_router(watcher_manager.router)
|
|||||||
from backend.routers import admin_modules
|
from backend.routers import admin_modules
|
||||||
app.include_router(admin_modules.router)
|
app.include_router(admin_modules.router)
|
||||||
|
|
||||||
from backend.routers import deployment_history
|
|
||||||
app.include_router(deployment_history.router)
|
|
||||||
|
|
||||||
from backend.routers import pending_deployments
|
|
||||||
app.include_router(pending_deployments.router)
|
|
||||||
|
|
||||||
# Projects system routers
|
# Projects system routers
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
@@ -275,30 +269,6 @@ async def tools_page(request: Request):
|
|||||||
return templates.TemplateResponse("tools.html", {"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)
|
@app.get("/modems", response_class=HTMLResponse)
|
||||||
async def modems_page(request: Request):
|
async def modems_page(request: Request):
|
||||||
"""Field modems management dashboard"""
|
"""Field modems management dashboard"""
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/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. "dBL" (default)
|
|
||||||
or "psi". Peaks and KPI tiles elsewhere are always dBL regardless.
|
|
||||||
|
|
||||||
Idempotent — safe to re-run.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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" in existing:
|
|
||||||
print("mic_unit_pref already exists — nothing to do.")
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"ALTER TABLE user_preferences "
|
|
||||||
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'dBL'"
|
|
||||||
)
|
|
||||||
# Backfill the single row that should exist (id=1) to the default,
|
|
||||||
# in case the column ends up NULL on existing rows.
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE user_preferences SET mic_unit_pref = 'dBL' "
|
|
||||||
"WHERE mic_unit_pref IS NULL"
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print("Added mic_unit_pref to user_preferences (default 'dBL').")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""
|
|
||||||
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,9 +135,6 @@ class UserPreferences(Base):
|
|||||||
calibration_warning_days = Column(Integer, default=30)
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
status_ok_threshold_hours = Column(Integer, default=12)
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
status_pending_threshold_hours = Column(Integer, default=24)
|
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. "dBL" (default) or "psi".
|
|
||||||
mic_unit_pref = Column(String, default="dBL")
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -647,58 +644,3 @@ class JobReservationUnit(Base):
|
|||||||
# Location identity
|
# Location identity
|
||||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||||
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
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,15 +49,6 @@ 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")
|
@router.get("/api/admin/sfm/overview")
|
||||||
async def admin_sfm_overview() -> JSONResponse:
|
async def admin_sfm_overview() -> JSONResponse:
|
||||||
"""Aggregated SFM diagnostic snapshot.
|
"""Aggregated SFM diagnostic snapshot.
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
"""
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
"""
|
|
||||||
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,7 +10,6 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
@@ -35,7 +34,7 @@ from backend.models import (
|
|||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401
|
from backend.utils.timezone import local_to_utc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
|
|
||||||
@@ -263,84 +262,6 @@ async def get_project_locations_json(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@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")
|
@router.post("/locations/create")
|
||||||
async def create_location(
|
async def create_location(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -724,19 +645,6 @@ async def assign_unit_to_location(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Assign a unit to a monitoring 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(
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
id=location_id,
|
id=location_id,
|
||||||
@@ -762,60 +670,32 @@ async def assign_unit_to_location(
|
|||||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse dates. Naive datetimes from datetime-local inputs are
|
# Check if location already has an active assignment (active = assigned_until IS NULL)
|
||||||
# interpreted as user-local and converted to UTC for storage; explicit
|
existing_assignment = db.query(UnitAssignment).filter(
|
||||||
# tz-aware ISO strings (Z / +00:00) skip the conversion.
|
and_(
|
||||||
def _parse_user_dt(s: str | None, field: str):
|
UnitAssignment.location_id == location_id,
|
||||||
if not s:
|
UnitAssignment.assigned_until == None,
|
||||||
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)
|
|
||||||
|
|
||||||
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="assigned_until must be after assigned_at.",
|
|
||||||
)
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
# Reject only if the new window overlaps an existing assignment at the
|
if existing_assignment:
|
||||||
# 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(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=(
|
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
||||||
f"New window overlaps an existing assignment at this "
|
|
||||||
f"location ({other.unit_id} {other_window}). Use swap or "
|
|
||||||
f"edit that record instead."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create new assignment
|
||||||
|
assigned_until_str = form_data.get("assigned_until")
|
||||||
|
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
|
||||||
|
|
||||||
assignment = UnitAssignment(
|
assignment = UnitAssignment(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
unit_id=unit_id,
|
unit_id=unit_id,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
device_type=unit.device_type,
|
device_type=unit.device_type,
|
||||||
assigned_at=assigned_at,
|
|
||||||
assigned_until=assigned_until,
|
assigned_until=assigned_until,
|
||||||
status="active" if assigned_until is None else "completed",
|
status="active",
|
||||||
notes=form_data.get("notes"),
|
notes=form_data.get("notes"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -872,20 +752,6 @@ async def unassign_unit(
|
|||||||
assignment.status = "completed"
|
assignment.status = "completed"
|
||||||
assignment.assigned_until = datetime.utcnow()
|
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()
|
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||||
_record_assignment_history(
|
_record_assignment_history(
|
||||||
db,
|
db,
|
||||||
@@ -920,11 +786,6 @@ async def update_assignment(
|
|||||||
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
||||||
- notes: string
|
- 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"
|
Sets `status` to "active" when assigned_until is cleared, "completed"
|
||||||
when it's set in the past.
|
when it's set in the past.
|
||||||
"""
|
"""
|
||||||
@@ -955,14 +816,12 @@ async def update_assignment(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
||||||
parsed = datetime.fromisoformat(raw)
|
new_assigned_at = datetime.fromisoformat(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid assigned_at datetime: {raw!r}",
|
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:
|
if "assigned_until" in payload:
|
||||||
raw = payload["assigned_until"]
|
raw = payload["assigned_until"]
|
||||||
@@ -970,13 +829,12 @@ async def update_assignment(
|
|||||||
new_assigned_until = None
|
new_assigned_until = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(raw)
|
new_assigned_until = datetime.fromisoformat(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid assigned_until datetime: {raw!r}",
|
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:
|
if "notes" in payload:
|
||||||
raw = payload["notes"]
|
raw = payload["notes"]
|
||||||
@@ -1334,22 +1192,6 @@ async def swap_unit_on_location(
|
|||||||
new_value=f"swapped out → {unit_id}",
|
new_value=f"swapped out → {unit_id}",
|
||||||
notes=notes,
|
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
|
# Create new assignment
|
||||||
new_assignment = UnitAssignment(
|
new_assignment = UnitAssignment(
|
||||||
@@ -1376,28 +1218,12 @@ async def swap_unit_on_location(
|
|||||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
if not modem:
|
if not modem:
|
||||||
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
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
|
unit.deployed_with_modem_id = modem_id
|
||||||
modem.deployed_with_unit_id = unit_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:
|
else:
|
||||||
# Clear modem pairing if not provided
|
# Clear modem pairing if not provided
|
||||||
unit.deployed_with_modem_id = None
|
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()
|
db.commit()
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1415,32 +1241,23 @@ async def swap_unit_on_location(
|
|||||||
async def get_available_modems(
|
async def get_available_modems(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
include_benched: bool = Query(False),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all non-retired modems for the modem assignment dropdown.
|
Get all deployed, 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.
|
|
||||||
"""
|
"""
|
||||||
filters = [
|
modems = db.query(RosterUnit).filter(
|
||||||
|
and_(
|
||||||
RosterUnit.device_type == "modem",
|
RosterUnit.device_type == "modem",
|
||||||
|
RosterUnit.deployed == True,
|
||||||
RosterUnit.retired == False,
|
RosterUnit.retired == False,
|
||||||
]
|
)
|
||||||
if not include_benched:
|
).order_by(RosterUnit.id).all()
|
||||||
filters.append(RosterUnit.deployed == True)
|
|
||||||
modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all()
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
"hardware_model": m.hardware_model,
|
"hardware_model": m.hardware_model,
|
||||||
"ip_address": m.ip_address,
|
"ip_address": m.ip_address,
|
||||||
"deployed": bool(m.deployed),
|
|
||||||
}
|
}
|
||||||
for m in modems
|
for m in modems
|
||||||
]
|
]
|
||||||
@@ -1451,31 +1268,22 @@ async def get_available_units(
|
|||||||
project_id: str,
|
project_id: str,
|
||||||
location_type: str = Query(...),
|
location_type: str = Query(...),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
include_benched: bool = Query(False),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of available units for assignment to a location.
|
Get list of available units for assignment to a location.
|
||||||
Filters by device type matching the location type.
|
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
|
# Determine required device type
|
||||||
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
||||||
|
|
||||||
# Get all units of the required type that aren't retired (and optionally
|
# Get all units of the required type that are deployed and not retired
|
||||||
# exclude benched units).
|
all_units = db.query(RosterUnit).filter(
|
||||||
filters = [
|
and_(
|
||||||
RosterUnit.device_type == required_device_type,
|
RosterUnit.device_type == required_device_type,
|
||||||
|
RosterUnit.deployed == True,
|
||||||
RosterUnit.retired == False,
|
RosterUnit.retired == False,
|
||||||
]
|
)
|
||||||
if not include_benched:
|
).all()
|
||||||
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)
|
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||||
@@ -1489,7 +1297,6 @@ async def get_available_units(
|
|||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"location": unit.address or unit.location,
|
"location": unit.address or unit.location,
|
||||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||||
"deployed": bool(unit.deployed),
|
|
||||||
}
|
}
|
||||||
for unit in all_units
|
for unit in all_units
|
||||||
if unit.id not in assigned_unit_ids
|
if unit.id not in assigned_unit_ids
|
||||||
|
|||||||
@@ -267,7 +267,6 @@ class PreferencesUpdate(BaseModel):
|
|||||||
calibration_warning_days: Optional[int] = None
|
calibration_warning_days: Optional[int] = None
|
||||||
status_ok_threshold_hours: Optional[int] = None
|
status_ok_threshold_hours: Optional[int] = None
|
||||||
status_pending_threshold_hours: Optional[int] = None
|
status_pending_threshold_hours: Optional[int] = None
|
||||||
mic_unit_pref: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences")
|
@router.get("/preferences")
|
||||||
@@ -294,7 +293,6 @@ def get_preferences(db: Session = Depends(get_db)):
|
|||||||
"calibration_warning_days": prefs.calibration_warning_days,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
|
|
||||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +334,6 @@ def update_preferences(
|
|||||||
"calibration_warning_days": prefs.calibration_warning_days,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
|
|
||||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
"""
|
|
||||||
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,35 +39,9 @@ from backend.services.sfm_events import (
|
|||||||
_fetch_events_for_serial,
|
_fetch_events_for_serial,
|
||||||
_iso_utc,
|
_iso_utc,
|
||||||
)
|
)
|
||||||
from backend.utils.timezone import utc_to_local
|
|
||||||
|
|
||||||
log = logging.getLogger("backend.services.deployment_timeline")
|
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
|
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
||||||
# clutter from a sub-second handoff during a swap workflow.
|
# clutter from a sub-second handoff during a swap workflow.
|
||||||
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
||||||
@@ -211,8 +185,8 @@ async def deployment_timeline_for_unit(
|
|||||||
overlays[a.id] = {
|
overlays[a.id] = {
|
||||||
"event_count": len(events),
|
"event_count": len(events),
|
||||||
"peak_pvs": peak,
|
"peak_pvs": peak,
|
||||||
"peak_pvs_at": _iso_local(peak_at),
|
"peak_pvs_at": peak_at,
|
||||||
"last_event": _iso_local(last_ev),
|
"last_event": last_ev,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||||
@@ -228,8 +202,8 @@ async def deployment_timeline_for_unit(
|
|||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"kind": "assignment",
|
"kind": "assignment",
|
||||||
"starts_at": _iso_local(a.assigned_at),
|
"starts_at": _iso_utc(a.assigned_at),
|
||||||
"ends_at": _iso_local(a.assigned_until),
|
"ends_at": _iso_utc(a.assigned_until),
|
||||||
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
||||||
"assignment_id": a.id,
|
"assignment_id": a.id,
|
||||||
"location_id": a.location_id,
|
"location_id": a.location_id,
|
||||||
@@ -253,8 +227,8 @@ async def deployment_timeline_for_unit(
|
|||||||
if gap_seconds >= _MIN_GAP_SECONDS:
|
if gap_seconds >= _MIN_GAP_SECONDS:
|
||||||
entries.append({
|
entries.append({
|
||||||
"kind": "gap",
|
"kind": "gap",
|
||||||
"starts_at": _iso_local(gap_start),
|
"starts_at": _iso_utc(gap_start),
|
||||||
"ends_at": _iso_local(gap_end),
|
"ends_at": _iso_utc(gap_end),
|
||||||
"duration_days": round(gap_seconds / 86400, 1),
|
"duration_days": round(gap_seconds / 86400, 1),
|
||||||
"context": "between assignments",
|
"context": "between assignments",
|
||||||
})
|
})
|
||||||
@@ -267,7 +241,7 @@ async def deployment_timeline_for_unit(
|
|||||||
continue
|
continue
|
||||||
entries.append({
|
entries.append({
|
||||||
"kind": "state_change",
|
"kind": "state_change",
|
||||||
"starts_at": _iso_local(h.changed_at),
|
"starts_at": _iso_utc(h.changed_at),
|
||||||
"ends_at": None,
|
"ends_at": None,
|
||||||
"duration_days": None,
|
"duration_days": None,
|
||||||
"change_type": h.change_type,
|
"change_type": h.change_type,
|
||||||
|
|||||||
@@ -28,27 +28,6 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const MODAL_ID = 'event-detail-modal';
|
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 = 'dBL'; // refreshed via fetch on first chart render
|
|
||||||
let _micUnitPrefLoaded = false; // one-shot fetch guard
|
|
||||||
|
|
||||||
function _esc(s) {
|
function _esc(s) {
|
||||||
if (s == null) return '';
|
if (s == null) return '';
|
||||||
return String(s).replace(/&/g, '&')
|
return String(s).replace(/&/g, '&')
|
||||||
@@ -245,370 +224,32 @@
|
|||||||
</div>`;
|
</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 === 'psi' ? 'psi' : 'dBL';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Network error → silent fall back to default 'dBL'.
|
|
||||||
}
|
|
||||||
_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) {
|
function _renderFileInfo(s, eventId) {
|
||||||
const bw = s.blastware || {};
|
const bw = s.blastware || {};
|
||||||
const src = s.source || {};
|
const src = s.source || {};
|
||||||
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
||||||
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
|
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 = `
|
const downloadButtons = `
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<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 ? `
|
${canDownloadBinary ? `
|
||||||
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
||||||
download="${_esc(bw.filename)}"
|
download="${_esc(bw.filename)}"
|
||||||
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">
|
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">
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Blastware binary
|
Download Blastware file
|
||||||
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span>
|
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
|
||||||
</a>
|
</a>
|
||||||
` : ''}
|
` : `
|
||||||
${txtFilename ? `
|
<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">
|
||||||
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
Original .TXT report
|
Blastware file unavailable
|
||||||
</a>
|
</span>
|
||||||
` : ''}
|
`}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="window.toggleEventJsonViewer()"
|
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">
|
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">
|
||||||
@@ -626,11 +267,6 @@
|
|||||||
Download sidecar JSON
|
Download sidecar JSON
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 id="event-json-viewer" class="hidden mb-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<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>
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
||||||
@@ -709,10 +345,6 @@
|
|||||||
${_sectionHeader('Peak Particle Velocity')}
|
${_sectionHeader('Peak Particle Velocity')}
|
||||||
${_renderPeakValues(s)}
|
${_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)) ? `
|
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
||||||
${_sectionHeader('Microphone')}
|
${_sectionHeader('Microphone')}
|
||||||
${_renderMic(s)}
|
${_renderMic(s)}
|
||||||
@@ -726,43 +358,14 @@
|
|||||||
${_renderDeviceMetadata(s)}
|
${_renderDeviceMetadata(s)}
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
${_sectionHeader('Review')}
|
|
||||||
${_renderReview(s, eventId)}
|
|
||||||
|
|
||||||
${_sectionHeader('Source File')}
|
${_sectionHeader('Source File')}
|
||||||
${_renderFileInfo(s, eventId)}
|
${_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 () {
|
window.closeEventDetailModal = function () {
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
if (modal) modal.classList.add('hidden');
|
if (modal) modal.classList.add('hidden');
|
||||||
_destroyCharts();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.toggleEventJsonViewer = function () {
|
window.toggleEventJsonViewer = function () {
|
||||||
@@ -773,71 +376,6 @@
|
|||||||
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
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 () {
|
window.copyEventJson = function () {
|
||||||
const pre = document.getElementById('event-json-pre');
|
const pre = document.getElementById('event-json-pre');
|
||||||
const label = document.getElementById('event-json-copy-label');
|
const label = document.getElementById('event-json-copy-label');
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ services:
|
|||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||||
- SFM_BASE_URL=http://sfm:8200
|
- 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
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
@@ -60,18 +56,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../seismo-relay/sfm/data:/app/sfm/data
|
- ../seismo-relay/sfm/data:/app/sfm/data
|
||||||
- ../seismo-relay/bridges/captures:/app/bridges/captures
|
- ../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:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8200
|
- 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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||||
|
|||||||
@@ -1,711 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,728 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
+8
-13
@@ -150,7 +150,6 @@
|
|||||||
(project tidy, metadata backfill, pair devices). #}
|
(project tidy, metadata backfill, pair devices). #}
|
||||||
{% set _is_tools = (
|
{% set _is_tools = (
|
||||||
request.url.path == '/tools'
|
request.url.path == '/tools'
|
||||||
or request.url.path.startswith('/tools/')
|
|
||||||
or request.url.path == '/pair-devices'
|
or request.url.path == '/pair-devices'
|
||||||
or request.url.path == '/settings/developer/project-tidy'
|
or request.url.path == '/settings/developer/project-tidy'
|
||||||
or request.url.path == '/settings/developer/metadata-backfill'
|
or request.url.path == '/settings/developer/metadata-backfill'
|
||||||
@@ -211,11 +210,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
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 %}">
|
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<div class="grid grid-cols-4 h-16">
|
<div class="grid grid-cols-4 h-16">
|
||||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
@@ -230,18 +225,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/deploy" onclick="window.location.href='/deploy'">
|
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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="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="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>Deploy</span>
|
<span>Devices</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
<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>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Events</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -10,74 +10,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-8 flex justify-between items-center gap-4 flex-wrap">
|
<div class="mb-8 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="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">
|
<div class="text-right">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
<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>
|
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pending-deployments banner — auto-shows when there are field captures
|
|
||||||
awaiting classification. Hides itself when count is 0. Polled
|
|
||||||
alongside the rest of the dashboard's 10-second refresh. -->
|
|
||||||
<a id="pending-deploy-banner" href="/tools/pending-deployments"
|
|
||||||
class="hidden mb-6 flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-9 h-9 rounded-lg bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-medium text-amber-900 dark:text-amber-200">
|
|
||||||
<span id="pending-deploy-count">0</span> field deployment<span id="pending-deploy-plural">s</span> awaiting classification
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-amber-700 dark:text-amber-300">Click to pick project / location for these captures</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function _refreshPendingDeployBanner() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/deployments/pending?status=awaiting');
|
|
||||||
if (!r.ok) return;
|
|
||||||
const data = await r.json();
|
|
||||||
const banner = document.getElementById('pending-deploy-banner');
|
|
||||||
const countEl = document.getElementById('pending-deploy-count');
|
|
||||||
const pluralEl = document.getElementById('pending-deploy-plural');
|
|
||||||
if (data.count > 0) {
|
|
||||||
countEl.textContent = data.count;
|
|
||||||
pluralEl.textContent = data.count === 1 ? '' : 's';
|
|
||||||
banner.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
banner.classList.add('hidden');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
/* silent — banner just stays hidden */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_refreshPendingDeployBanner();
|
|
||||||
setInterval(_refreshPendingDeployBanner, 30000);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Dashboard cards with auto-refresh -->
|
<!-- Dashboard cards with auto-refresh -->
|
||||||
<div hx-get="/api/status-snapshot"
|
<div hx-get="/api/status-snapshot"
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
{% 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 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-0 bg-black/60" onclick="closeEventDetailModal()"></div>
|
||||||
<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="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 z-10">
|
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
||||||
<button onclick="closeEventDetailModal()"
|
<button onclick="closeEventDetailModal()"
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||||
@@ -23,7 +23,3 @@ Usage:
|
|||||||
<div id="event-detail-modal-content"></div>
|
<div id="event-detail-modal-content"></div>
|
||||||
</div>
|
</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,159 +0,0 @@
|
|||||||
<!-- 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,19 +78,128 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Location map — uses the reusable partial that fetches from
|
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
||||||
/api/projects/{p}/locations-json. Same render is reused on the
|
overview. Operators get a quick visual of where their locations
|
||||||
deeper Vibration tab so both surfaces stay in sync. #}
|
sit relative to each other. Pins clickable → scroll to + flash
|
||||||
{% with project_id=project.id %}
|
the matching card. Locations without coordinates land in a
|
||||||
{% include 'partials/projects/location_map.html' %}
|
"missing coords" hint below the map.
|
||||||
{% endwith %}
|
For projects with scheduled monitoring activity, the full
|
||||||
</div>
|
Upcoming Actions list is still available on the Schedules tab. -->
|
||||||
|
<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>
|
||||||
{% if upcoming_actions %}
|
{% 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')"
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
class="text-seismo-orange hover:text-seismo-navy">
|
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
||||||
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- `isolation: isolate` forces a new stacking context so Leaflet's
|
||||||
|
internal z-indexes (panes at 200-700, controls at 800) stay
|
||||||
|
contained inside this div instead of leaking into the root
|
||||||
|
stacking context and rendering over modals (which have z-50). -->
|
||||||
|
<div id="project-location-map" class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
style="height: 320px; background: rgba(0,0,0,0.05); isolation: isolate;"></div>
|
||||||
|
<div id="project-location-map-empty" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
|
||||||
|
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
|
||||||
|
</div>
|
||||||
|
<div id="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Build location data from server-side render. Skip removed
|
||||||
|
// locations (their pins would clutter the active operations view)
|
||||||
|
// and skip ones without parseable coordinates.
|
||||||
|
const locationsRaw = [
|
||||||
|
{% for loc in locations %}
|
||||||
|
{% if not loc.removed_at %}
|
||||||
|
{
|
||||||
|
id: {{ loc.id | tojson }},
|
||||||
|
name: {{ loc.name | tojson }},
|
||||||
|
coords: {{ loc.coordinates | tojson if loc.coordinates else 'null' }},
|
||||||
|
}{% if not loop.last %},{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseCoords(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||||
|
if (parts.length !== 2 || parts.some(isNaN)) return null;
|
||||||
|
const [lat, lon] = parts;
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
|
||||||
|
const withCoords = [];
|
||||||
|
const withoutCoords = [];
|
||||||
|
for (const loc of locationsRaw) {
|
||||||
|
const xy = parseCoords(loc.coords);
|
||||||
|
if (xy) withCoords.push({ ...loc, latlon: xy });
|
||||||
|
else withoutCoords.push(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMsg = document.getElementById('project-location-map-empty');
|
||||||
|
const missingMsg = document.getElementById('project-location-map-missing');
|
||||||
|
const mapEl = document.getElementById('project-location-map');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
// Hide the map block and show a hint. Don't init Leaflet at all.
|
||||||
|
mapEl.classList.add('hidden');
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise Leaflet. `L` is loaded globally by base.html.
|
||||||
|
const map = L.map(mapEl, { scrollWheelZoom: false });
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markers = [];
|
||||||
|
const bounds = [];
|
||||||
|
withCoords.forEach(loc => {
|
||||||
|
const marker = L.circleMarker(loc.latlon, {
|
||||||
|
radius: 8,
|
||||||
|
fillColor: '#f48b1c',
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
}).addTo(map);
|
||||||
|
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
|
||||||
|
marker.on('click', () => _flashLocationCard(loc.id));
|
||||||
|
markers.push(marker);
|
||||||
|
bounds.push(loc.latlon);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds.length === 1) {
|
||||||
|
map.setView(bounds[0], 14);
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: [20, 20] });
|
||||||
|
}
|
||||||
|
// Without this the map renders into a 0×0 area when the partial
|
||||||
|
// first lands via htmx (container size not yet stable).
|
||||||
|
setTimeout(() => map.invalidateSize(), 100);
|
||||||
|
|
||||||
|
if (withoutCoords.length > 0) {
|
||||||
|
const names = withoutCoords.map(l => l.name).join(', ');
|
||||||
|
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
|
||||||
|
missingMsg.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Briefly highlight the matching card to confirm the click.
|
||||||
|
function _flashLocationCard(locId) {
|
||||||
|
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
card.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -101,8 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
<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">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
||||||
<button onclick="openLocationModal('vibration')"
|
<button onclick="openLocationModal('vibration')"
|
||||||
@@ -120,16 +119,6 @@
|
|||||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+1
-35
@@ -122,21 +122,6 @@
|
|||||||
How often the dashboard should refresh automatically
|
How often the dashboard should refresh automatically
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="dBL" selected>dB(L) — sound pressure level</option>
|
|
||||||
<option value="psi">psi — raw pressure</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>
|
</div>
|
||||||
|
|
||||||
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
@@ -604,20 +589,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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
|
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
||||||
operator workflows, not admin/dev surfaces). Find them
|
operator workflows, not admin/dev surfaces). Find them
|
||||||
at /tools. #}
|
at /tools. #}
|
||||||
@@ -786,9 +757,6 @@ async function loadPreferences() {
|
|||||||
// Load auto-refresh interval
|
// Load auto-refresh interval
|
||||||
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
|
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 || 'dBL';
|
|
||||||
|
|
||||||
// Load status thresholds
|
// Load status thresholds
|
||||||
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
||||||
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
|
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
|
||||||
@@ -806,7 +774,6 @@ async function saveGeneralSettings() {
|
|||||||
const timezone = document.getElementById('timezone-select').value;
|
const timezone = document.getElementById('timezone-select').value;
|
||||||
const theme = document.querySelector('input[name="theme"]:checked').value;
|
const theme = document.querySelector('input[name="theme"]:checked').value;
|
||||||
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
|
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
|
||||||
const micUnitPref = document.getElementById('mic-unit-pref').value;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/settings/preferences', {
|
const response = await fetch('/api/settings/preferences', {
|
||||||
@@ -815,8 +782,7 @@ async function saveGeneralSettings() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
timezone,
|
timezone,
|
||||||
theme,
|
theme,
|
||||||
auto_refresh_interval: autoRefreshInterval,
|
auto_refresh_interval: autoRefreshInterval
|
||||||
mic_unit_pref: micUnitPref
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -118,13 +118,6 @@
|
|||||||
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<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>
|
<style>
|
||||||
.sfm-tab {
|
.sfm-tab {
|
||||||
|
|||||||
@@ -14,43 +14,6 @@
|
|||||||
<!-- Card grid -->
|
<!-- Card grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<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 -->
|
<!-- Pair Devices -->
|
||||||
<a href="/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">
|
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">
|
||||||
@@ -105,24 +68,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</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) -->
|
<!-- Reports (per-project) -->
|
||||||
<a href="/projects"
|
<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">
|
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">
|
||||||
@@ -141,24 +86,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</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) -->
|
<!-- 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="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
|
|||||||
+1
-495
@@ -281,17 +281,12 @@
|
|||||||
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
||||||
unit_assignments + unit_history + SFM event overlay) -->
|
unit_assignments + unit_history + SFM event overlay) -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<div class="flex justify-between items-center mb-4 flex-wrap gap-2">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
||||||
<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">
|
<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
|
↻ Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gantt chart — visual timeline of all deployments. Click
|
<!-- Gantt chart — visual timeline of all deployments. Click
|
||||||
a bar to jump to its row in the list below. -->
|
a bar to jump to its row in the list below. -->
|
||||||
@@ -307,119 +302,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- SFM Events (seismographs only) -->
|
||||||
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
|
<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">
|
<div class="flex justify-between items-center mb-4">
|
||||||
@@ -497,20 +379,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Event table -->
|
||||||
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
<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">
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
@@ -2259,16 +2127,6 @@ function _dtRenderAssignment(e) {
|
|||||||
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 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)}">
|
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">
|
<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>
|
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
||||||
@@ -2281,7 +2139,6 @@ function _dtRenderAssignment(e) {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
${mergeableBadge}
|
${mergeableBadge}
|
||||||
${activeBadge}
|
${activeBadge}
|
||||||
${actionButtons}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1">${locLink}</div>
|
<div class="mt-1">${locLink}</div>
|
||||||
@@ -2584,261 +2441,6 @@ function renderDeploymentTimeline(entries, container, mergeGroups) {
|
|||||||
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
|
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 ──────────────────────────────────────────────────────
|
// ── SFM Events section ──────────────────────────────────────────────────────
|
||||||
function clearUnitEventFilters() {
|
function clearUnitEventFilters() {
|
||||||
document.getElementById('ue-filter-bucket').value = 'all';
|
document.getElementById('ue-filter-bucket').value = 'all';
|
||||||
@@ -3050,13 +2652,7 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
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)}')">
|
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 text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
|
||||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
||||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
||||||
@@ -3072,11 +2668,6 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
<table class="w-full text-left">
|
<table class="w-full text-left">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
<tr>
|
<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('Timestamp', 'timestamp')}
|
||||||
${_ueSortableTh('Tran', 'tran_ppv')}
|
${_ueSortableTh('Tran', 'tran_ppv')}
|
||||||
${_ueSortableTh('Vert', 'vert_ppv')}
|
${_ueSortableTh('Vert', 'vert_ppv')}
|
||||||
@@ -3088,85 +2679,6 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
</table>`;
|
</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 =====
|
// ===== Pair Device Modal Functions =====
|
||||||
@@ -3720,11 +3232,5 @@ function showToast(message, type = 'info') {
|
|||||||
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -992,10 +992,4 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
|||||||
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user