Compare commits
109 Commits
904ff04440
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fcd1261b4 | |||
| 576e4f89ca | |||
| 5f02a0bc21 | |||
| 684a487203 | |||
| 04cd6b9f24 | |||
| fdd0426884 | |||
| fe7cf91488 | |||
| c1bc391ba2 | |||
| ccb70698ba | |||
| 88887a92d8 | |||
| a81764d4bc | |||
| a555cb74dd | |||
| 505c2e3ca5 | |||
| 1d49b54bd1 | |||
| c1b5efae56 | |||
| f760e81309 | |||
| 4839d14a22 | |||
| fa7dc39e5e | |||
| 0914cf0a75 | |||
| 29b974a1f7 | |||
| bececafe78 | |||
| 7fb4ba0343 | |||
| 2da9493cb5 | |||
| b2c54caebd | |||
| b908f394ed | |||
| 5455d3a931 | |||
| b971d19068 | |||
| 0103917870 | |||
| 3fc20e104a | |||
| c5b5045603 | |||
| 2031681d0f | |||
| dd77f27cf6 | |||
| 1cf80ea7ea | |||
| 08d3d53702 | |||
| 786a9821a3 | |||
| a82bf59fb6 | |||
| 26b4b1e7e4 | |||
| d3e221b6b1 | |||
| 9f40210057 | |||
| 6c048a9c30 | |||
| 80a8470b55 | |||
| a64c9ced65 | |||
| 90ec943a0b | |||
| 846807965c | |||
| ed195ed96b | |||
| 182e224f3c | |||
| 2e832708f3 | |||
| 5e3645e229 | |||
| 88f258d1c7 | |||
| 711ef41e5f | |||
| e27aef33ac | |||
| 170dedb138 | |||
| d92d01dc56 | |||
| 17a1a83bdf | |||
| f5e93d5612 | |||
| bdc91177e2 | |||
| 3b818dcd97 | |||
| 61b144efd2 | |||
| c56b7f6c99 | |||
| 08fec696f1 | |||
| 7f561c2c9d | |||
| 38f2c751b8 | |||
| 78d72431b3 | |||
| 6c41ccf1bd | |||
| 56bd3041cf | |||
| 623ef648b7 | |||
| 5ed00bf70e | |||
| 43c804d0c4 | |||
| c1f995b4d3 | |||
| 2905a327be | |||
| 4b2bb9a9c9 | |||
| 1d9fd00cc2 | |||
| db8d666aa1 | |||
| b2bfa6d268 | |||
| d64b9450a1 | |||
| a073b9b06e | |||
| 502bf5bbeb | |||
| 472c25372d | |||
| 6d37bd759e | |||
| 44ab4d8427 | |||
| aaf9399bb3 | |||
| ef6484c350 | |||
| 8cffd7dd5e | |||
| ba4cf9e560 | |||
| 1af5a94f57 | |||
| e05f2189c4 | |||
| 7ed94cd8fc | |||
| 2b8e9168c3 | |||
| 75597ec1c4 | |||
| 4dcfcbdc45 | |||
| 825c7370b8 | |||
| 47c65268e3 | |||
| ba9cdb4347 | |||
| f063383e61 | |||
| 17c988c1ee | |||
| d297412d8a | |||
| 52dd6c3e32 | |||
| 295f9637b3 | |||
| ad55d4ca09 | |||
| ba1f28ee53 | |||
| c48c6e5bca | |||
| ef0008822e | |||
| f13158e7bf | |||
| 3f0ec8f30b | |||
| d5a0163852 | |||
| fd37425f1c | |||
| 4378290c9c | |||
| 9775dca114 | |||
| 32d2a57bc9 |
@@ -220,7 +220,6 @@ marimo/_static/
|
|||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
# Seismo Fleet Manager
|
# Seismo Fleet Manager
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
@@ -228,6 +227,3 @@ __marimo__/
|
|||||||
/data/
|
/data/
|
||||||
/data-dev/
|
/data-dev/
|
||||||
.aider*
|
.aider*
|
||||||
.aider*
|
|
||||||
=======
|
|
||||||
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027
|
|
||||||
|
|||||||
+438
@@ -5,6 +5,444 @@ 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).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
SLM live monitoring — fan-out feed + cache-first reads. Targets **0.14.0**. The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed. This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand. Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
|
||||||
|
- **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0.
|
||||||
|
- **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
|
||||||
|
- **Live Measurements panel auto-populates from cache.** Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit. Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale). Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
|
||||||
|
- **Refresh buttons** — one per device-list row, one in the panel header. On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
|
||||||
|
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Dashboard device list + command center read SLMM's cache, not the device.** `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load. This stops dashboard loads from stealing the device's single connection from the live monitor. The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
|
||||||
|
- **Device-list freshness reflects real monitoring.** The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates. The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
|
||||||
|
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.** `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`. The deploy POST had already succeeded, so the operator saw both the green success **and** a red error. Both call sites now guard on the element existing (`slm_settings_modal.html`).
|
||||||
|
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
|
||||||
|
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
|
||||||
|
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
|
||||||
|
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
|
||||||
|
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
|
||||||
|
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py
|
||||||
|
|
||||||
|
# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
|
||||||
|
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
The two builds must ship **together**. Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Client portal *(new — read-only client-facing view)*
|
||||||
|
|
||||||
|
A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
|
||||||
|
locations, live. Built inside Terra-View (no new service), reusing the cached
|
||||||
|
SLMM feed; every route resolves the client through one swappable
|
||||||
|
`get_current_client` gate, so the interim magic/open-link auth can be replaced
|
||||||
|
(M4) without touching routes or templates. Strictly read-only — no device control.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a
|
||||||
|
`Project.client_id` FK. A signed (HMAC) session cookie carries the access-token
|
||||||
|
id, re-validated against the DB each request (revoke kills live sessions, with
|
||||||
|
server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a
|
||||||
|
dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
|
||||||
|
- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
|
||||||
|
instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
|
||||||
|
the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream
|
||||||
|
**auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
|
||||||
|
cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
|
||||||
|
- **Locations overview.** Live status map (level-colored dots, dark/light CARTO
|
||||||
|
tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the
|
||||||
|
headline metric.
|
||||||
|
- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail
|
||||||
|
page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
|
||||||
|
read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
|
||||||
|
in the portal; enabling a rule pins that device's monitor on so alerts evaluate
|
||||||
|
round-the-clock.
|
||||||
|
- **Operator sharing tools.** A **"View client portal"** preview button and a
|
||||||
|
**"Copy client link"** modal (mint / list / revoke magic links) on the project
|
||||||
|
page, plus a `backend/portal_admin.py` CLI.
|
||||||
|
- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI +
|
||||||
|
IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
|
||||||
|
**light/dark toggle** (light default, persisted, no-flash).
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
|
||||||
|
- All scoping enforced server-side (404-not-403, no existence leak); client
|
||||||
|
endpoints return **scrubbed** projections (no device-health/internal ids); WS
|
||||||
|
frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
|
||||||
|
Pre-merge code review hardened cookie expiry, open-links default, and the slug
|
||||||
|
collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
|
||||||
|
tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".
|
||||||
|
|
||||||
|
#### Upgrade Notes
|
||||||
|
|
||||||
|
- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
|
||||||
|
(adds `projects.client_id`; the `clients` / `client_access_tokens` tables
|
||||||
|
auto-create).
|
||||||
|
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
|
||||||
|
and keep **`PORTAL_OPEN_LINKS=false`** there.
|
||||||
|
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
|
||||||
|
cooldown + keepalive coupling) — same build pairing as above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.3] - 2026-06-05
|
||||||
|
|
||||||
|
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Calibration sync service** (`backend/services/calibration_sync.py`). Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`. Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
|
||||||
|
- **Conflict rule: events-as-truth, manual wins when newer.** Three outcomes per unit:
|
||||||
|
- `already_in_sync` — stored date already matches the event's calibration date.
|
||||||
|
- `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved. Only a future event can supersede it.
|
||||||
|
- `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
|
||||||
|
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`). Started in `main.py`'s startup hook, stopped on shutdown. Does not run on boot — first sync after a server start fires at the next 03:15.
|
||||||
|
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`. Powers the Settings button.
|
||||||
|
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows. Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
|
||||||
|
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab). Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
|
||||||
|
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist. Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**. The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it. Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist. Rebuild only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.2] - 2026-05-30
|
||||||
|
|
||||||
|
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
|
||||||
|
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
|
||||||
|
|
||||||
|
### What mobile users will see after deploy
|
||||||
|
|
||||||
|
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.1] - 2026-05-29
|
||||||
|
|
||||||
|
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
|
||||||
|
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-05-29
|
||||||
|
|
||||||
|
The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
|
||||||
|
|
||||||
|
### Added — Event-detail modal: Chart.js waveform/histogram panels
|
||||||
|
|
||||||
|
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
|
||||||
|
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
|
||||||
|
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
|
||||||
|
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
|
||||||
|
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
|
||||||
|
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
|
||||||
|
|
||||||
|
### Added — Event-detail modal: PDF preview + downloads + review form
|
||||||
|
|
||||||
|
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
|
||||||
|
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
|
||||||
|
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
|
||||||
|
|
||||||
|
### Added — `/admin/events` row click opens the modal
|
||||||
|
|
||||||
|
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
|
||||||
|
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
|
||||||
|
|
||||||
|
### Added — `mic_unit_pref` user setting (Settings → General)
|
||||||
|
|
||||||
|
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
|
||||||
|
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
|
||||||
|
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
|
||||||
|
|
||||||
|
### Fixed — Docker Compose: SFM container can finally read the DB
|
||||||
|
|
||||||
|
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
# Apply the new column to the database — required. Idempotent.
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||||
|
|
||||||
|
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
|
||||||
|
docker compose build terra-view && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
|
||||||
|
|
||||||
|
### What's NOT in this release
|
||||||
|
|
||||||
|
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12.1] - 2026-05-20
|
||||||
|
|
||||||
|
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
|
||||||
|
|
||||||
|
### Added — Unit Swap wizard (`/tools/unit-swap`)
|
||||||
|
|
||||||
|
- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm. Designed for tap-driven use on a phone in the field; works on desktop too.
|
||||||
|
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf. Each row carries a `deployed` boolean for badge rendering. Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
|
||||||
|
- **`POST /locations/{loc}/swap` enhancements**:
|
||||||
|
- Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` flag consistent with the active-assignment signal.
|
||||||
|
- Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), the stale back-reference is broken before re-pairing.
|
||||||
|
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
|
||||||
|
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.
|
||||||
|
|
||||||
|
### Added — Editable deployment timeline on `/unit/{id}`
|
||||||
|
|
||||||
|
- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline. Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes. Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
|
||||||
|
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment. Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
|
||||||
|
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows. Closed historical assignments that don't overlap an existing one are accepted (the backfill case).
|
||||||
|
- After any save/delete the timeline reloads and the SFM-events list re-fetches, so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`). The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever. All three now: close the previous active assignment, break the outgoing unit's modem pairing (both directions), and set `deployed = False` on the outgoing unit. Unassign and swap also clear the modem's back-reference. Promote-pending additionally handles the case where the target location already has an active assignment — previously this silently created two active assignments at the same location; now the old one is closed (`assigned_until = pending.capture_time`, `status = completed`), the old unit benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
|
||||||
|
- **Deployment timeline now respects user timezone for display *and* edits.** Timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen. Two-sided fix:
|
||||||
|
- **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing. Frontend slicing keeps working — it just slices a local-time string now.
|
||||||
|
- **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`. Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
No schema changes. Static code-only release — pull and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-05-17
|
||||||
|
|
||||||
|
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
|
||||||
|
|
||||||
|
### Added — field-deployment workflow
|
||||||
|
|
||||||
|
- **`/deploy` — mobile-first 3-step capture wizard**: pick unit → take photo (opens phone camera via `<input capture="environment">`) → optional note → submit. Designed for under-90-seconds-on-site. Success page shows captured coords and links back to "Deploy another" or the pending hopper.
|
||||||
|
- **`/tools/pending-deployments` — the hopper**: filter pills Awaiting / Assigned / Cancelled. Each card has photo thumbnail, unit link, coords, operator note, status-appropriate actions.
|
||||||
|
- **Classify modal**: two modes — assign to existing project+location, OR create new location with new-or-existing project + a "use captured coords" checkbox that writes the pending row's coords onto the new location record.
|
||||||
|
- **`PendingDeployment` data model** (`pending_deployments` table): lifecycle `awaiting → assigned | cancelled`. Photo file lives under `data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_<uuid8>.<ext>`. Migration: `backend/migrate_add_pending_deployments.py` (idempotent).
|
||||||
|
- **Backend endpoints**:
|
||||||
|
- `POST /api/deployments/capture` — multipart upload (unit_id, photo, optional note), EXIF GPS extraction, seismograph-only (rejects others with 400)
|
||||||
|
- `GET /api/deployments/pending` — list by status
|
||||||
|
- `GET /api/deployments/pending/{id}` — single row detail
|
||||||
|
- `POST /api/deployments/pending/{id}/promote` — classify and create `UnitAssignment`; events in the assignment window get retroactively attributed via the existing metadata-backfill mechanism
|
||||||
|
- `POST /api/deployments/pending/{id}/cancel` — abandon with optional reason
|
||||||
|
- `GET /api/deployments/seismograph-picker` — JSON list for the `/deploy` picker, annotated with `has_pending`
|
||||||
|
- **Discovery surfaces**: orange "Field Deploy" button on the desktop dashboard header (md+), bottom-nav slot 3 on mobile (Menu / Dashboard / Deploy / Events; Devices moved into the Menu drawer), `/tools` cards for both Field Deploy and Pending Deployments, dashboard banner that auto-shows when awaiting captures exist (polled every 30s, hides at 0).
|
||||||
|
- **Full audit trail**: every capture / promote / cancel writes a `UnitHistory` row (`pending_deployment_captured` / `_promoted` / `_cancelled`).
|
||||||
|
|
||||||
|
### Added — fleet-wide deployment history
|
||||||
|
|
||||||
|
- **`/tools/deployment-history` — fleet-wide 12-month calendar** (Phase 2 of the per-unit Gantt from v0.11.0). 4-month-per-row grid styled like the Job Planner, responsive to single column on mobile. Each day cell shows up to 4 deterministically-colored mini-bars (one per active project that day), with "+N" overflow. KPI strip across the top: project count, distinct unit count, total assignment count in the window. Collapsible project legend ordered by first-active date.
|
||||||
|
- **Click-a-day side panel**: slide-over from the right, groups by project, lists every (unit, location) active that day with auto-backfilled tags, sourced from new `GET /api/admin/deployment-history/day`.
|
||||||
|
- **Prev / Next / Recent month navigation**: shifts the 12-month window by 1 month. Default window is 11 months back from current → operator sees recent past on first load, not future emptiness.
|
||||||
|
- **Gantt by Project tab**: horizontal time-axis bars per project, hover for tooltip with unit + location + window. Reduced opacity for closed assignments, blue outline for metadata-backfilled, today dashed-orange line.
|
||||||
|
- **Gantt by Unit tab**: same idea inverted — one row per seismograph, bars colored by project. Natural for "where has BE11529 been across all my jobs?" Service layer returns a `units` array with bars carrying baked-in `project_color`.
|
||||||
|
- **Tab switcher with hash sync**: `#gantt` / `#byunit` preserved across month-paging. Tab registry (`_DH_TABS`) makes adding future views a one-line addition.
|
||||||
|
|
||||||
|
### Added — SFM event DB management
|
||||||
|
|
||||||
|
- **`/admin/events` — SFM Event DB Manager** under Developer Tools. Cross-unit event browser with filters (serial, from/to, false_trigger, limit), checkbox selection with select-all, and bulk actions:
|
||||||
|
- **Delete selected** — hard-delete chosen rows from SFM's `events` table
|
||||||
|
- **Delete ALL matching current filter** — dry-run first to show match count + sample serials in confirm dialog; only proceeds on explicit confirmation
|
||||||
|
- Same Flag-as-FT / Clear-FT bulk actions for convenience
|
||||||
|
- **Destructive operations also clean up on-disk files**: associated `.AB0*` blastware binary, `.a5.pkl`, `.sfm.json` sidecar, and `.h5` files are unlinked alongside the DB row. Cannot be undone — the manager has a prominent red warning banner and a `max_rows` safety cap (10,000) that refuses oversized deletes without explicit acknowledgment.
|
||||||
|
- **Designed for cleaning bogus events from a misbehaving unit** — a stuck-triggered seismograph can dump hundreds of junk events into SFM before it's recovered; this is the operator's broom.
|
||||||
|
- **`unit_detail.html` also gains bulk false-trigger flagging**: same checkbox UX as the DB manager, but with **🚩 Flag as false trigger** / **✓ Clear false trigger** instead of delete (delete is admin-only via `/admin/events`). Concurrent fan-out (8 in flight) for fast bulk PATCH.
|
||||||
|
|
||||||
|
### Added — maps, navigation, polish
|
||||||
|
|
||||||
|
- **Reusable location-map partial** (`templates/partials/projects/location_map.html`): self-contained map div + self-fetch script. Accepts `project_id`, `map_height`, `location_type` filter. Project overview's inline map (~150 lines of JS) replaced with a 1-line include; Vibration tab on the project detail page now uses the same partial with `location_type='vibration'` at 450px height.
|
||||||
|
- **Hover location card → highlight matching map pin** on the project overview map. Enlarges + reddens the pin, opens its tooltip. Bidirectional with the existing pin → card flash. Event delegation on `document` so cards from htmx swaps keep the behavior without rewiring.
|
||||||
|
- **Mobile bottom-nav swap Settings → Events**: Settings (rarely needed in the field) replaced by Events (the daily mobile destination since the SFM integration). Settings/Projects/Tools/admin pages still in the Menu drawer.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`/deploy` photo input now allows gallery picks**: `capture="environment"` was forcing mobile browsers to open the camera and skip "Photo Library" / "Choose File". Useful at the install site, problematic when uploading a photo taken earlier. Attribute removed; chooser now offers both options. EXIF extraction works identically.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
One new migration this release. Idempotent and non-destructive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or sweep all migrations at once (safe — already-applied ones no-op):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in backend/migrate_*.py; do
|
||||||
|
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
New table: `pending_deployments` — capture rows for the field-deployment workflow. Empty after migration; populated as field techs use `/deploy`.
|
||||||
|
|
||||||
|
**Deploy order matters**: run the migration BEFORE the new code is up, or the running app will 500 on the missing table. Same gotcha that bit the v0.10.0 → v0.11.0 deploy.
|
||||||
|
|
||||||
|
**SFM bump pairing**: this release pairs with seismo-relay v0.17.0, which adds the `DELETE /db/events/{id}` and `POST /db/events/delete_bulk` endpoints that `/admin/events` consumes. An older SFM will return 405/404 for those routes; the manager will surface the error in its result alert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-05-15
|
||||||
|
|
||||||
|
Operator-facing polish release. All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events. Cascade-closes active unit assignments and cancels pending scheduled actions at the location. Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming). Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
|
||||||
|
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page. Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups. Click a bar to scroll the matching list row into view with a flash highlight.
|
||||||
|
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record). Now auto-detected and surfaceable in the timeline header — one click combines them into a single record. Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
|
||||||
|
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment. Trash icon in each row of the location's Deployment History panel. Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history. Writes an `assignment_deleted` UnitHistory row.
|
||||||
|
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left. Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`. Implementation uses native HTML5 drag-and-drop (no library). New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
|
||||||
|
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu. Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
|
||||||
|
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations still show session counts.
|
||||||
|
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map. One pin per active monitoring location (parsed from the `coordinates` field). Click pin → scrolls + flashes the matching card. Tooltip on hover. Locations without coordinates surface as an inline hint below the map. If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores. Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio. Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens. Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90. Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler. Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
|
||||||
|
- **Location Remove button** on the project page
|
||||||
|
- **Metadata-backfill typeahead dropdown** (existing project + location pickers)
|
||||||
|
- **Project-merge typeahead dropdown** (in the per-project header)
|
||||||
|
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
|
||||||
|
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`. Fixed by adding `isolation: isolate` to the map container.
|
||||||
|
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`. Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying. Both migrations are idempotent and non-destructive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or sweep all migrations at once (safe — already-applied ones no-op):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in backend/migrate_*.py; do
|
||||||
|
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
New columns added this release:
|
||||||
|
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
|
||||||
|
- `monitoring_locations.removal_reason` (TEXT, nullable)
|
||||||
|
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration
|
||||||
|
|
||||||
|
**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns. Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-05-14
|
||||||
|
|
||||||
|
This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **SFM Integration**: New fleet-wide events page at `/sfm` listing every event ingested by SFM, with filters for serial, date range, false-trigger flag, and limit. Unit detail pages and project-location pages show their own attributed subsets of the same event stream.
|
||||||
|
- **Event Detail Modal**: Shared across `/sfm`, unit detail, and project-location pages — clicking any event opens a rich modal showing peaks per channel (PVS color-coded by magnitude), microphone dB(L) + ZC frequency + time of peak, sensor self-check table with pass/fail per channel, device/recording metadata (firmware, battery, calibration date, geo range), and download buttons for the original Blastware binary and the sidecar JSON. Includes an inline pretty-printed JSON viewer with copy-to-clipboard.
|
||||||
|
- **Events Attribution Engine** (`backend/services/sfm_events.py`): Per-event attribution against `UnitAssignment` time windows. Events outside any assignment window surface in an "Unattributed" bucket with the nearest-assignment diagnostic (which location, signed delta in days).
|
||||||
|
- **Metadata Backfill Tool** (`/tools` → Backfill from event metadata): Scans operator-typed `project` and `sensor_location` strings in event sidecars, fuzzy-clusters them via `rapidfuzz.WRatio`, and proposes retroactive `UnitAssignment` records to attribute orphan events. Tracks operator decisions per cluster across re-scans.
|
||||||
|
- **Project Tidy Tool** (`/tools` → Project Tidy): Fuzzy-detect duplicate projects and bulk-merge them with a single click. Source projects soft-deleted with full audit trail.
|
||||||
|
- **Vibration Summary on Project Pages**: New roll-up card on vibration project detail pages showing per-location event counts, the project's "Overall Peak" PVS (false triggers excluded), last event timestamp, and a Top Locations by Activity list.
|
||||||
|
- **SFM-Primary Seismograph Status**: `emit_status_snapshot()` now consults SFM's `/db/units` (cached 15s) before falling back to `Emitter.last_seen` for each seismograph. The fresher signal wins; the choice is recorded in a new per-unit `last_seen_source` field. A small `SFM` (orange) or `HB` (gray) badge on each unit's active-table row shows which path is currently driving the status.
|
||||||
|
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moved to a horizontal collapsible card below the Fleet Map, auto-expanding only when pending actions exist. Recent Call-Ins now sources from a new `/api/recent-event-callins` endpoint backed by SFM event forwards instead of the watcher-heartbeat endpoint.
|
||||||
|
- **Sortable Events Tables**: `/sfm` and unit-detail SFM Events tables now have clickable column headers with ↕/↓/↑ indicators. Default sort is Timestamp DESC. Click same column to toggle direction; click different column to switch and reset to DESC. Pure client-side over cached rows — no re-fetches.
|
||||||
|
- **Developer → SFM Admin** (`/admin/sfm`): Health banner with reachability indicator, terra-view↔SFM connection panel, 4 KPI tiles (known units, total events, stale `monitor_log` rows, stale `ach_sessions` rows), per-unit roll-up table, recent-events table with color-coded forwarding latency (so stale watcher forwards stand out), and a raw API tester for any `/api/sfm/*` path.
|
||||||
|
- **Developer → SLMM Admin** (`/admin/slmm`): Stripped-down companion page — health, connection info, raw API tester.
|
||||||
|
- **Tools Workflow Hub** (`/tools`): New top-level sidebar entry consolidating Pair Devices, Project Tidy, Metadata Backfill, Reports (info card), and Swap Detection (placeholder).
|
||||||
|
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices) replacing five separate sidebar items.
|
||||||
|
- **Synology Deployment Doc** (`docs/SYNOLOGY_DEPLOYMENT.md`): End-to-end playbook for migrating the stack to an always-on office NAS — phased rollout (pre-stage, data rsync, watcher repoint, external access, decommission), Tailscale vs reverse-proxy options, rollback plan, and gotchas.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Overall Peak excludes false triggers**: The project-level "Overall Peak" KPI tile (and the underlying `_compute_stats()` function in `sfm_events.py`) now skip events flagged as false triggers when computing the highest PVS, so operators see the highest real event rather than the biggest sensor glitch. `false_trigger_count` still includes flagged events so operators can see how many were filtered out.
|
||||||
|
- **`RosterUnit.note` Editing**: Inline edit on seismograph cards is more forgiving and now auto-saves on blur.
|
||||||
|
- **Sidebar Nav Renamed**: Old "Fleet" sidebar entry → "Devices" (renamed because it always meant the device list, not the broader fleet view).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Status drift between watcher heartbeat and actual event arrivals**: Seismographs are now reported with whichever signal is more recent — eliminates the case where a unit had recent SFM events but a stale heartbeat (or vice-versa) showed the wrong status.
|
||||||
|
- **Event modal: Record Type always showed "Waveform"**: Workaround client-side — Record Type now derived from the Blastware filename's last-char code (`H`=Histogram, `W`=Waveform, `M`=Manual, `E`=Event, `C`=Combo). The proper fix lives in SFM's sidecar parser; tracked separately.
|
||||||
|
- **Event modal: Mic PSI tile removed**: Operators only care about dB(L); the redundant PSI tile was dropped.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying. Every migration is idempotent.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cleanest: re-run all migrations in chronological order.
|
||||||
|
# Already-applied migrations no-op safely.
|
||||||
|
for f in backend/migrate_*.py; do
|
||||||
|
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations new in this release:
|
||||||
|
- `migrate_add_metadata_backfill.py` — adds `unit_assignments.source` column and `metadata_backfill_decisions` table for the Metadata Backfill tool
|
||||||
|
|
||||||
|
### Deployment Notes
|
||||||
|
- **`SFM_BASE_URL`**: Confirm prod's `docker-compose.yml` sets this for the terra-view service (typically `http://sfm:8200` for the in-stack SFM container, or an external URL if SFM lives elsewhere).
|
||||||
|
- **Watcher repoint**: series3-watcher's `sfm_forward_url` should point at `https://<your-terra-view-host>/api/sfm` (proxy-based — no second port forward needed). Watcher composes the full path `/db/import/blastware_file` itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.9.4] - 2026-04-06
|
## [0.9.4] - 2026-04-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.9.4
|
# Terra-View v0.13.3
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -9,15 +9,20 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
|
|||||||
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
||||||
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
||||||
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
||||||
|
- **Field-Deployment Workflow**: One-photo mobile capture at `/deploy` → desk-side classification at `/tools/pending-deployments` → automatic UnitAssignment creation with EXIF GPS
|
||||||
|
- **Unit Swap Wizard** (`/tools/unit-swap`): mobile-first 4-step flow for swapping a vibration unit (and optionally its modem) at a monitoring location. Surfaces benched-fleet candidates as eligible incoming units; cleans up stale modem back-references on swap
|
||||||
|
- **Editable Deployment Timeline** on every unit detail page: inline edit / delete each assignment, plus an "Add deployment record" button for backfilling historical windows. Frees-up previously-orphaned events when their timestamp now falls inside an assignment
|
||||||
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
||||||
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
||||||
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
||||||
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
||||||
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
||||||
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
||||||
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
- **SFM Event DB Manager** (`/admin/events`): cross-unit event browser with bulk false-trigger flagging and admin-only hard-delete (cleans on-disk binaries + sidecars too) for purging bogus events from misbehaving units
|
||||||
|
- **Deployment-History Calendar + Gantt** (`/tools/deployment-history`): fleet-wide 12-month calendar with side-panel day drill-down, plus "Gantt by Project" / "Gantt by Unit" tabs
|
||||||
- **Photo Management**: Upload and view photos for each unit
|
- **Photo Management**: Upload and view photos for each unit
|
||||||
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab)
|
||||||
|
- **Timezone-Aware Timeline**: deployment assignments display and edit in the user's configured local timezone; UTC stays canonical on disk
|
||||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||||
- **Database Management**: Comprehensive backup and restore system
|
- **Database Management**: Comprehensive backup and restore system
|
||||||
- **Manual Snapshots**: Create on-demand backups with descriptions
|
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||||
@@ -496,6 +501,32 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
|
### v0.11.0 — 2026-05-15
|
||||||
|
- **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page.
|
||||||
|
- **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view.
|
||||||
|
- **Merge Consecutive Deployments**: Auto-detects runs of same-location assignments within a 7-day gap and offers a one-click "Merge into one" button. Preserves notes, ingest source, and writes an `assignment_merged` audit entry.
|
||||||
|
- **Delete Assignment for Mis-Clicks**: Trash icon on each row of the location's Deployment History panel. Hard-deletes the assignment with a safety check that refuses if real MonitoringSessions sit inside the window (those should go through Unassign instead).
|
||||||
|
- **Drag-to-Reorder Location Cards**: Six-dot drag handle on each card; drop order persists via a new `/locations/reorder` endpoint. Removed locations stay sorted by removal date (their order is historical).
|
||||||
|
- **Three-Dot Kebab Menu**: Replaces the inline Unassign / Edit / Remove / Delete pill row with a single ⋮ menu. Much cleaner card layout, especially for projects with many locations.
|
||||||
|
- **Event Count on Vibration Cards**: Vibration locations now show "{N} events" instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations are unchanged.
|
||||||
|
- **Project Location Map**: Right column of the project overview is now a Leaflet map with a pin per location. Click pin → scrolls + flashes the matching card. Replaces the lightly-used Upcoming Actions panel (still discoverable via a link to the Schedules tab when actions exist).
|
||||||
|
- **Stricter Location Fuzzy Matching**: Metadata-backfill no longer suggests obviously-wrong matches. WRatio was over-confident on location names ("Area 2 - Brookville Dam - Loc 2" vs "Area 1 - Loc 1 - 87 Jenks" used to score 86%); now uses `token_set_ratio` + a multi-digit penalty so disjoint address numbers correctly demote the score.
|
||||||
|
- **Fixed: Multiple typeahead dropdowns weren't clickable**: Same JSON.stringify quote-collision bug surfaced in three places (location Remove button, backfill typeahead, project-merge dropdown). All three fixed by switching to `data-*` attributes + trampoline functions.
|
||||||
|
- **Fixed: Merge-project modal had to be scrolled to see options**: Modal body's `flex-1 overflow-y-auto` collapsed too tight; added `min-height` so the dropdown has room to render below the input.
|
||||||
|
|
||||||
|
### v0.10.0 — 2026-05-14
|
||||||
|
- **SFM Integration**: terra-view now consumes events from the SFM (Seismograph Field Module) backend in real time, with a fleet-wide events page at `/sfm`, per-unit attribution against project assignment windows, and a project-level vibration roll-up that uses SFM data as the single source of truth.
|
||||||
|
- **SFM-Primary Seismograph Status**: Deployed seismograph status (OK/Pending/Missing) now flows from SFM event forwards first; the watcher heartbeat stays as a transparent backup. Each unit's active table row shows a small `SFM` or `HB` badge so operators can see at a glance which signal is currently driving the status.
|
||||||
|
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moves to a horizontal collapsible card below the Fleet Map, auto-expanding only when there's a pending action. Recent Call-Ins now sources from SFM event forwards instead of the legacy watcher-heartbeat endpoint.
|
||||||
|
- **Event Detail Modal**: Click any event anywhere in the app to open a rich detail modal showing peak particle velocity per channel, microphone dB(L), sensor self-check results, device/recording metadata, and download buttons for the original Blastware binary and sidecar JSON. Includes an inline JSON viewer with one-click copy.
|
||||||
|
- **Sortable Events Tables**: Every events table (project events, unit-detail events, fleet-wide /sfm) now supports clickable column-header sorting with directional indicators. Defaults to newest-first.
|
||||||
|
- **Events Attribution & Backfill**: Each SFM event is automatically attributed to a project/location based on `UnitAssignment` time windows. Unattributed events get a diagnostic showing the nearest assignment and a delta-days gap. The metadata-backfill tool in `/tools` scans operator-typed project/sensor-location strings in event sidecars and clusters them via fuzzy matching to propose new assignment retroactives.
|
||||||
|
- **Projects Tools**: New `/tools` workflow hub consolidates Pair Devices, Project Tidy (fuzzy-detect + merge duplicate projects), Metadata Backfill, Reports, and Swap Detection (placeholder).
|
||||||
|
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices).
|
||||||
|
- **Developer → SFM Admin**: New `/admin/sfm` page surfacing SFM health, per-unit roll-up from `/db/units`, recent-events table with forwarding latency (so operators can spot stale watcher forwards), stale-table counts, and a raw API tester. Companion `/admin/slmm` page covers SLMM health + raw API.
|
||||||
|
- **"Overall Peak" excludes False Triggers**: The project-level Overall Peak KPI tile now excludes events flagged as false triggers — operators see the highest real event, not the biggest sensor glitch.
|
||||||
|
- **Synology Deployment Doc**: New `docs/SYNOLOGY_DEPLOYMENT.md` covers migrating the stack to an always-on office NAS, including phased rollout, data rsync, watcher repoint, external-access (Tailscale or reverse-proxy), and rollback plan.
|
||||||
|
|
||||||
### v0.8.0 — 2026-03-18
|
### v0.8.0 — 2026-03-18
|
||||||
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
|
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
|
||||||
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
|
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
|
||||||
@@ -599,9 +630,23 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.8.0** — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
|
**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
|
||||||
|
|
||||||
Previous: 0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
|
Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
||||||
|
|
||||||
|
0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
||||||
|
|
||||||
|
0.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
|
||||||
|
|
||||||
|
0.9.2 — Deployment records, allocated status, quick-info unit modal, inline seismograph editing (2026-03-27)
|
||||||
|
|
||||||
|
0.9.1 — Fix location slots not persisting on save/reload (2026-03-20)
|
||||||
|
|
||||||
|
0.9.0 — Job Planner redesign, monitoring locations, estimated units, smart color picker, calendar bar tooltips, toast notifications (2026-03-19)
|
||||||
|
|
||||||
|
0.8.0 — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
|
||||||
|
|
||||||
|
0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
|
||||||
|
|
||||||
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# FTP Report Pipeline — session brief
|
||||||
|
|
||||||
|
**Branch:** `feat/ftp-report-pipeline` (off `dev`), worktree `/home/serversdown/terra-view-reports`.
|
||||||
|
**Scope:** Terra-View only. Do NOT touch SLMM — the SLMM alert/monitor work is live in a
|
||||||
|
parallel session on `slmm` branch `feat/drd-fix`. Pull device data through the **existing**
|
||||||
|
SLMM FTP proxy endpoints; add no SLMM code (for v1).
|
||||||
|
|
||||||
|
See memory note `client_sound_monitoring_job_2026-07` for the client requirements + timeline.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Automated **daily morning report** for the John Myler 3-location sound job: each AM, last
|
||||||
|
night's noise levels vs the **baseline week**, per location. Data pulled from the meters via
|
||||||
|
FTP (the meter records 24/7 to SD regardless of TCP wedges). Alerts are a *separate* workstream
|
||||||
|
(SLMM, real-time DOD) — not in scope here.
|
||||||
|
|
||||||
|
## The big realization (why this is small)
|
||||||
|
The hard parts already exist:
|
||||||
|
- **SLMM (use as-is, via the `/api/slmm/...` proxy):**
|
||||||
|
- `GET /api/slmm/{unit}/ftp/files?path=/NL-43` → list files/folders
|
||||||
|
- `POST /api/slmm/{unit}/ftp/download-folder` → returns the `Auto_####` folder as a **ZIP**
|
||||||
|
- **Terra-View ingest (reuse):** `backend/routers/project_locations.py:1743` `upload_nrl_data`
|
||||||
|
already accepts a **ZIP**, extracts, keeps `.rnh` + `_Leq_ .rnd` (drops `_Lp_`/junk via
|
||||||
|
`_is_wanted`), runs `_parse_rnh` (line 1687) → creates `MonitoringSession` + `DataFile`.
|
||||||
|
- **Report generator (reuse, source-agnostic):** `backend/routers/projects.py`. The `.rnd`
|
||||||
|
file reads funnel through 3 helpers — `_peek_rnd_headers` (~135), `_is_leq_file` (~147),
|
||||||
|
`_read_rnd_file_rows` (~256). `.rnd` files live on disk under `data/{file_path}` (DataFile
|
||||||
|
holds the path, not a BLOB). The stats/Excel/formatting logic doesn't care where bytes come from.
|
||||||
|
|
||||||
|
## Build (Terra-View)
|
||||||
|
1. **Refactor** `upload_nrl_data`'s core into a callable `ingest_nrl_zip(location_id, zip_bytes, db)`
|
||||||
|
so it can be invoked programmatically (not only via HTTP UploadFile).
|
||||||
|
2. **Scheduled pull job** (reuse the existing scheduler): per project location/unit →
|
||||||
|
`GET /ftp/files` to find new `Auto_####` folders → `POST /ftp/download-folder` (zip) →
|
||||||
|
`ingest_nrl_zip(...)`. **Dedup** so repeated pulls don't duplicate sessions/files
|
||||||
|
(track ingested folder names per location).
|
||||||
|
3. **Baseline aggregation:** aggregate the baseline-week `_Leq_` intervals per location →
|
||||||
|
reference values (nighttime Leq, L90 floor, typical Lmax).
|
||||||
|
4. **Nightly report + email:** compute last night's metrics per location, compare to baseline
|
||||||
|
(deltas), render (reuse the Excel/report machinery), email each morning.
|
||||||
|
|
||||||
|
## Data-location decision (light version, agreed)
|
||||||
|
Keep `MonitoringSession`/`DataFile` **metadata in TV** for now; reuse the existing on-disk file
|
||||||
|
store. Optional refinement (later): have SLMM keep the pulled files and TV read them through a
|
||||||
|
SLMM file-serve endpoint (avoids the copy-into-TV step). Don't do that refinement under the
|
||||||
|
deadline unless trivial — the report logic is identical either way.
|
||||||
|
|
||||||
|
## Open questions to resolve early
|
||||||
|
1. **What's actually in a `_Leq_ .rnd`** — Leq only, or Leq + Lmax + Ln per 15-min interval?
|
||||||
|
Decides whether the night-vs-baseline report can show L90/Lmax or just Leq. Inspect a real file.
|
||||||
|
2. **Session rollover / dedup** — does a 2-week run write one growing `Auto_####` folder or new
|
||||||
|
folders? Drives the "what's new" logic.
|
||||||
|
3. **`download-folder` over a multi-day run** — confirm it zips cleanly (size/time).
|
||||||
|
|
||||||
|
## Client params (confirm with Dave before locking)
|
||||||
|
Threshold/metric + their "night" window; report recipients + format (email body vs PDF/Excel).
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
Setup ~7/1–7/2 (baseline week), shutdown week through ~7/17. Reports needed by ~7/8 (before
|
||||||
|
shutdown). Today is ~3 weeks out — reliability > features.
|
||||||
+143
-3
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
@@ -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.9.4"
|
VERSION = "0.13.3"
|
||||||
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":
|
||||||
@@ -66,6 +66,21 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
|||||||
# Use shared templates configuration with timezone filters
|
# Use shared templates configuration with timezone filters
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
# Client-portal auth: an unauthenticated portal request renders the access page
|
||||||
|
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
|
||||||
|
# portal route can simply Depends(get_current_client).
|
||||||
|
from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS
|
||||||
|
|
||||||
|
@app.exception_handler(PortalAuthError)
|
||||||
|
async def portal_auth_handler(request: Request, exc: PortalAuthError):
|
||||||
|
if request.url.path.startswith("/portal/api"):
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/access_required.html",
|
||||||
|
{"request": request, "reason": "required"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
# Add custom context processor to inject environment variable into all templates
|
# Add custom context processor to inject environment variable into all templates
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def add_environment_to_context(request: Request, call_next):
|
async def add_environment_to_context(request: Request, call_next):
|
||||||
@@ -97,6 +112,10 @@ app.include_router(slmm.router)
|
|||||||
app.include_router(slm_ui.router)
|
app.include_router(slm_ui.router)
|
||||||
app.include_router(slm_dashboard.router)
|
app.include_router(slm_dashboard.router)
|
||||||
app.include_router(seismo_dashboard.router)
|
app.include_router(seismo_dashboard.router)
|
||||||
|
|
||||||
|
# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md
|
||||||
|
from backend.routers import portal
|
||||||
|
app.include_router(portal.router)
|
||||||
app.include_router(sfm.router)
|
app.include_router(sfm.router)
|
||||||
app.include_router(modem_dashboard.router)
|
app.include_router(modem_dashboard.router)
|
||||||
|
|
||||||
@@ -109,6 +128,12 @@ 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)
|
||||||
@@ -138,9 +163,18 @@ app.include_router(fleet_calendar.router)
|
|||||||
from backend.routers import deployments
|
from backend.routers import deployments
|
||||||
app.include_router(deployments.router)
|
app.include_router(deployments.router)
|
||||||
|
|
||||||
|
# Calibration sync router (SFM-driven cal date updates)
|
||||||
|
from backend.routers import calibration
|
||||||
|
app.include_router(calibration.router)
|
||||||
|
|
||||||
|
# Nightly sound-report pipeline (manual triggers; scheduled tick reuses run_nightly_report)
|
||||||
|
from backend.routers import reports
|
||||||
|
app.include_router(reports.router)
|
||||||
|
|
||||||
# Start scheduler service and device status monitor on application startup
|
# Start scheduler service and device status monitor on application startup
|
||||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||||
|
from backend.services.calibration_sync import get_calibration_sync_scheduler
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
@@ -153,6 +187,10 @@ async def startup_event():
|
|||||||
await start_device_status_monitor()
|
await start_device_status_monitor()
|
||||||
logger.info("Device status monitor started")
|
logger.info("Device status monitor started")
|
||||||
|
|
||||||
|
logger.info("Starting calibration sync scheduler...")
|
||||||
|
get_calibration_sync_scheduler().start()
|
||||||
|
logger.info("Calibration sync scheduler started")
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def shutdown_event():
|
def shutdown_event():
|
||||||
"""Clean up services on app shutdown"""
|
"""Clean up services on app shutdown"""
|
||||||
@@ -164,6 +202,10 @@ def shutdown_event():
|
|||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
logger.info("Scheduler service stopped")
|
logger.info("Scheduler service stopped")
|
||||||
|
|
||||||
|
logger.info("Stopping calibration sync scheduler...")
|
||||||
|
get_calibration_sync_scheduler().stop()
|
||||||
|
logger.info("Calibration sync scheduler stopped")
|
||||||
|
|
||||||
|
|
||||||
# Legacy routes from the original backend
|
# Legacy routes from the original backend
|
||||||
from backend import routes as legacy_routes
|
from backend import routes as legacy_routes
|
||||||
@@ -269,6 +311,30 @@ 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"""
|
||||||
@@ -347,10 +413,84 @@ async def project_detail_page(request: Request, project_id: str):
|
|||||||
"""Project detail dashboard"""
|
"""Project detail dashboard"""
|
||||||
return templates.TemplateResponse("projects/detail.html", {
|
return templates.TemplateResponse("projects/detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id
|
"project_id": project_id,
|
||||||
|
"portal_open_links": PORTAL_OPEN_LINKS,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/portal-preview")
|
||||||
|
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Operator testing shortcut: log into the client portal scoped to this project
|
||||||
|
(auto-provisioning a client/link if needed), no CLI. Lives under /projects (not
|
||||||
|
/portal), so a public proxy that exposes only /portal/* won't expose this."""
|
||||||
|
from backend.models import Project
|
||||||
|
from backend.portal_auth import (
|
||||||
|
provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
|
||||||
|
)
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||||
|
token_id = provision_preview_session(project, db)
|
||||||
|
resp = RedirectResponse(url="/portal", status_code=303)
|
||||||
|
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
||||||
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/portal-link")
|
||||||
|
async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Mint a fresh shareable client link for this project's client. Returns the
|
||||||
|
full /portal/enter/<token> URL (shown once). Operator-only (internal app)."""
|
||||||
|
from backend.models import Project
|
||||||
|
from backend.portal_auth import ensure_project_client, mint_link_token
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||||
|
client = ensure_project_client(project, db)
|
||||||
|
raw = mint_link_token(client, db, label="shared link")
|
||||||
|
url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}"
|
||||||
|
return {"url": url, "client_name": client.name}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/portal-links")
|
||||||
|
async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""List active (non-revoked) shareable links for this project's client."""
|
||||||
|
from backend.models import Project, ClientAccessToken, Client
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project or not project.client_id:
|
||||||
|
return {"client_name": None, "links": []}
|
||||||
|
client = db.query(Client).filter_by(id=project.client_id).first()
|
||||||
|
toks = (db.query(ClientAccessToken)
|
||||||
|
.filter_by(client_id=project.client_id, revoked_at=None)
|
||||||
|
.order_by(ClientAccessToken.created_at.desc()).all())
|
||||||
|
return {
|
||||||
|
"client_name": client.name if client else None,
|
||||||
|
"links": [{
|
||||||
|
"id": t.id, "label": t.label,
|
||||||
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||||
|
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
|
||||||
|
} for t in toks],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/portal-link/{token_id}/revoke")
|
||||||
|
async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Revoke one shareable link (scoped to this project's client). Kills the link
|
||||||
|
and any live session minted from it on the next request."""
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
from backend.models import Project, ClientAccessToken
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project or not project.client_id:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||||
|
tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first()
|
||||||
|
if not tok:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Link not found"})
|
||||||
|
if not tok.revoked_at:
|
||||||
|
tok.revoked_at = _dt.utcnow()
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
||||||
async def nrl_detail_page(
|
async def nrl_detail_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Client Portal (M1).
|
||||||
|
|
||||||
|
Adds the authoritative client link to projects:
|
||||||
|
- projects.client_id (TEXT, nullable) -> clients.id
|
||||||
|
|
||||||
|
The `clients` and `client_access_tokens` tables are created automatically by
|
||||||
|
SQLAlchemy `create_all` at app startup (they're brand-new tables), so this
|
||||||
|
migration only handles the column that create_all won't add to an existing
|
||||||
|
`projects` table.
|
||||||
|
|
||||||
|
Run once per database:
|
||||||
|
docker exec terra-view-terra-view-1 python3 backend/migrate_add_client_portal.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
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("A fresh DB created via models.py will include projects.client_id automatically.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
existing = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
if "client_id" not in existing:
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN client_id TEXT")
|
||||||
|
print("✓ Added column: projects.client_id (TEXT)")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"✗ Failed to add projects.client_id: {e}")
|
||||||
|
else:
|
||||||
|
print("○ Column already exists: projects.client_id")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("\n✓ Client-portal migration complete.")
|
||||||
|
print(" Note: `clients` + `client_access_tokens` tables auto-create on app startup.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `removed_at` + `removal_reason` columns to `monitoring_locations`.
|
||||||
|
|
||||||
|
Lets operators mark a location as no longer actively monitored without
|
||||||
|
deleting it (so historical events stay attributed correctly). Mirrors
|
||||||
|
the timestamp-based "closed state" pattern already used by
|
||||||
|
`unit_assignments.assigned_until`.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- `removed_at IS NULL` → location is active (default for all existing
|
||||||
|
rows after this migration)
|
||||||
|
- `removed_at` set → location is removed; historical events still
|
||||||
|
attribute to it but it's hidden from active
|
||||||
|
surfaces (assign dropdowns, calendar, etc.)
|
||||||
|
- `removal_reason` → optional operator note (e.g. "client dropped
|
||||||
|
from scope")
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
|
||||||
|
cur.execute(f"PRAGMA table_info({table})")
|
||||||
|
return any(row[1] == column for row in cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database() -> None:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
added = []
|
||||||
|
if not _has_column(cur, "monitoring_locations", "removed_at"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removed_at DATETIME")
|
||||||
|
added.append("removed_at")
|
||||||
|
if not _has_column(cur, "monitoring_locations", "removal_reason"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removal_reason TEXT")
|
||||||
|
added.append("removal_reason")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns to monitoring_locations: {', '.join(added)}")
|
||||||
|
else:
|
||||||
|
print(" monitoring_locations already has removed_at + removal_reason — nothing to do.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add removed_at + removal_reason to monitoring_locations")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `sort_order` column to `monitoring_locations` and seed
|
||||||
|
existing rows.
|
||||||
|
|
||||||
|
Lets operators reorder location cards via drag-and-drop on the project
|
||||||
|
detail page. Lower sort_order renders first; ties fall back to name.
|
||||||
|
|
||||||
|
Seed strategy: for each existing project, assign sort_order = 0, 1, 2, …
|
||||||
|
to its locations in their current alphabetical-by-name order. After
|
||||||
|
this migration, the visible card order on every existing project will
|
||||||
|
be unchanged.
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
|
||||||
|
cur.execute(f"PRAGMA table_info({table})")
|
||||||
|
return any(row[1] == column for row in cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database() -> None:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
added_column = False
|
||||||
|
if not _has_column(cur, "monitoring_locations", "sort_order"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN sort_order INTEGER DEFAULT 0")
|
||||||
|
added_column = True
|
||||||
|
print(" Added column: monitoring_locations.sort_order")
|
||||||
|
|
||||||
|
# Seed: for each project, set sort_order to its alphabetical index.
|
||||||
|
# Re-runs are harmless — operator-edited orderings can be re-seeded by
|
||||||
|
# passing FORCE_RESEED=1, but the default behavior leaves existing
|
||||||
|
# nonzero sort_order values alone so we don't clobber user choices.
|
||||||
|
force_reseed = os.environ.get("FORCE_RESEED") == "1"
|
||||||
|
if added_column or force_reseed:
|
||||||
|
cur.execute("SELECT DISTINCT project_id FROM monitoring_locations")
|
||||||
|
projects = [r[0] for r in cur.fetchall()]
|
||||||
|
seeded = 0
|
||||||
|
for project_id in projects:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM monitoring_locations WHERE project_id = ? ORDER BY name",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
for idx, (loc_id,) in enumerate(cur.fetchall()):
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_locations SET sort_order = ? WHERE id = ?",
|
||||||
|
(idx, loc_id),
|
||||||
|
)
|
||||||
|
seeded += 1
|
||||||
|
print(f" Seeded sort_order for {seeded} location(s) across {len(projects)} project(s).")
|
||||||
|
else:
|
||||||
|
print(" monitoring_locations.sort_order already present — leaving existing values alone.")
|
||||||
|
print(" (Set FORCE_RESEED=1 to re-seed by alphabetical order.)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add sort_order to monitoring_locations")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Add mic_unit_pref column to user_preferences.
|
||||||
|
|
||||||
|
Adds a single field controlling the mic channel's unit on the event-
|
||||||
|
report waveform chart in the SFM event detail modal. "psi" (default —
|
||||||
|
matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles
|
||||||
|
elsewhere are always dBL regardless.
|
||||||
|
|
||||||
|
History: v0.13.0 originally shipped this with default "dBL", which
|
||||||
|
made the website chart inconsistent with the PDF. v0.13.1 flips the
|
||||||
|
default to "psi" so they match. This migration is idempotent and
|
||||||
|
covers three cases:
|
||||||
|
|
||||||
|
1. Fresh DB without the column — adds it with default 'psi'.
|
||||||
|
2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to
|
||||||
|
'psi' on the assumption no operator deliberately picked 'dBL' yet.
|
||||||
|
3. DB upgraded from later — flip step is a no-op for non-'dBL' values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
possible_paths = [
|
||||||
|
Path("data/seismo_fleet.db"),
|
||||||
|
Path("data/sfm.db"),
|
||||||
|
Path("data/seismo.db"),
|
||||||
|
]
|
||||||
|
db_path = next((p for p in possible_paths if p.exists()), None)
|
||||||
|
if db_path is None:
|
||||||
|
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||||
|
print("Will be created with the new column when models.py initialises.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("PRAGMA table_info(user_preferences)")
|
||||||
|
existing = {row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if "mic_unit_pref" not in existing:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE user_preferences "
|
||||||
|
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
|
||||||
|
)
|
||||||
|
# Backfill any rows where the column ended up NULL.
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||||
|
"WHERE mic_unit_pref IS NULL"
|
||||||
|
)
|
||||||
|
print("Added mic_unit_pref column (default 'psi').")
|
||||||
|
else:
|
||||||
|
print("mic_unit_pref column already exists.")
|
||||||
|
|
||||||
|
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
|
||||||
|
# 'dBL' default get bumped to 'psi'. If any operator deliberately
|
||||||
|
# chose 'dBL' through Settings before this migration runs they'd
|
||||||
|
# get reset — acceptable trade-off given the small user base and
|
||||||
|
# the fact the setting is one click to restore.
|
||||||
|
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||||
|
"WHERE mic_unit_pref = 'dBL'")
|
||||||
|
flipped = cur.rowcount
|
||||||
|
if flipped:
|
||||||
|
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `pending_deployments` table.
|
||||||
|
|
||||||
|
Stores "I just installed this seismograph" captures from the field.
|
||||||
|
A pending deployment is the prospective form of a UnitAssignment —
|
||||||
|
captured at install time (photo + coords + maybe a free-text note),
|
||||||
|
classified later (project + location chosen at a desk).
|
||||||
|
|
||||||
|
Once classified, a real UnitAssignment is created, the pending row's
|
||||||
|
status flips to "assigned", and resulting_assignment_id points at the
|
||||||
|
new assignment for audit.
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database() -> None:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_deployments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
unit_id TEXT NOT NULL,
|
||||||
|
captured_at DATETIME NOT NULL,
|
||||||
|
coordinates TEXT,
|
||||||
|
operator_note TEXT,
|
||||||
|
photo_filename TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'awaiting',
|
||||||
|
promoted_at DATETIME,
|
||||||
|
resulting_assignment_id TEXT,
|
||||||
|
cancelled_at DATETIME,
|
||||||
|
cancelled_reason TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" Table 'pending_deployments' ready.")
|
||||||
|
|
||||||
|
# Indexes — operators will query by status (hopper list) and by
|
||||||
|
# unit_id (per-unit detail page → "is there a pending capture?").
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_deployments_status
|
||||||
|
ON pending_deployments (status)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_deployments_unit_id
|
||||||
|
ON pending_deployments (unit_id)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_deployments_captured_at
|
||||||
|
ON pending_deployments (captured_at)
|
||||||
|
""")
|
||||||
|
print(" Indexes ready.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add pending_deployments table")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -135,6 +135,11 @@ class UserPreferences(Base):
|
|||||||
calibration_warning_days = Column(Integer, default=30)
|
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. "psi" (default — matches
|
||||||
|
# the PDF report) or "dBL". Default flipped in v0.13.1 after
|
||||||
|
# operator feedback that the chart should mirror the PDF.
|
||||||
|
mic_unit_pref = Column(String, default="psi")
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,6 +192,7 @@ class Project(Base):
|
|||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
|
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||||
start_date = Column(Date, nullable=True)
|
start_date = Column(Date, nullable=True)
|
||||||
@@ -213,6 +219,35 @@ class ProjectModule(Base):
|
|||||||
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundReportConfig(Base):
|
||||||
|
"""
|
||||||
|
Per-project configuration for the automated nightly sound report
|
||||||
|
(FTP report pipeline). One row per project. Read by the morning tick in
|
||||||
|
SchedulerService and by the manual /reports endpoints (as defaults).
|
||||||
|
|
||||||
|
New table → created by Base.metadata.create_all() on startup; no migration
|
||||||
|
needed (only a rebuild/restart).
|
||||||
|
"""
|
||||||
|
__tablename__ = "sound_report_configs"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
|
||||||
|
project_id = Column(String, nullable=False, index=True, unique=True) # FK to projects.id
|
||||||
|
|
||||||
|
enabled = Column(Boolean, default=False, nullable=False) # run the daily report?
|
||||||
|
report_time = Column(String, default="08:00", nullable=False) # local HH:MM to run/send
|
||||||
|
metric_keys = Column(String, default="lmax,l01,l10,l90", nullable=False) # csv of metric keys
|
||||||
|
# Baseline source: "captured" = compute from recorded nights in the date range below;
|
||||||
|
# "reference" = use fixed values typed per location (old-report averages or a spec limit).
|
||||||
|
baseline_mode = Column(String, default="captured", nullable=False)
|
||||||
|
baseline_start = Column(Date, nullable=True) # captured-mode range
|
||||||
|
baseline_end = Column(Date, nullable=True)
|
||||||
|
recipients = Column(Text, nullable=True) # csv; falls back to REPORT_SMTP_RECIPIENTS env
|
||||||
|
last_run_date = Column(Date, nullable=True) # evening-date of the last reported night (dedup)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class MonitoringLocation(Base):
|
class MonitoringLocation(Base):
|
||||||
"""
|
"""
|
||||||
Monitoring locations: generic location for monitoring activities.
|
Monitoring locations: generic location for monitoring activities.
|
||||||
@@ -235,6 +270,20 @@ class MonitoringLocation(Base):
|
|||||||
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
||||||
location_metadata = Column(Text, nullable=True)
|
location_metadata = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Soft-removal: NULL means active. When set, the location is hidden from
|
||||||
|
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
|
||||||
|
# vibration summary) but historical events generated before this time
|
||||||
|
# still attribute to it. Mirrors the closed-state pattern used by
|
||||||
|
# UnitAssignment.assigned_until.
|
||||||
|
removed_at = Column(DateTime, nullable=True)
|
||||||
|
removal_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Display order within the project's location list. Operators can
|
||||||
|
# drag-and-drop to reorder cards on the project detail page. Lower
|
||||||
|
# values render first; ties fall back to name (alphabetical). Seeded
|
||||||
|
# to alphabetical-index on migration; new locations get max+1.
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -630,3 +679,92 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CLIENT PORTAL — read-only, scoped client access (see docs/CLIENT_PORTAL.md)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class Client(Base):
|
||||||
|
"""A portal client (customer org). Owns one or more Projects via
|
||||||
|
Project.client_id; their portal surfaces only those projects' locations.
|
||||||
|
Read-only — clients never control devices."""
|
||||||
|
__tablename__ = "clients"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
name = Column(String, nullable=False) # display name, e.g. "PJ Dick"
|
||||||
|
slug = Column(String, nullable=False, unique=True, index=True) # URL-safe handle
|
||||||
|
contact_email = Column(String, nullable=True) # for M4 magic-link
|
||||||
|
active = Column(Boolean, default=True) # False = portal access off
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientAccessToken(Base):
|
||||||
|
"""Interim 'magic URL' gate (M1-M3). The raw secret lives in the link and is
|
||||||
|
shown once on creation; only its sha256 is stored here. Revoke by setting
|
||||||
|
revoked_at. In M4 this is replaced behind get_current_client() without
|
||||||
|
touching routes/templates."""
|
||||||
|
__tablename__ = "client_access_tokens"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
client_id = Column(String, nullable=False, index=True) # FK -> clients.id
|
||||||
|
token_hash = Column(String, nullable=False, index=True) # sha256 hex of the secret
|
||||||
|
label = Column(String, nullable=True) # e.g. "Dave's link"
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
|
revoked_at = Column(DateTime, nullable=True) # set = link no longer works
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Client-portal admin CLI (M1). Operator tooling — run inside the terra-view
|
||||||
|
container against the live DB. The raw magic-link token is shown ONCE on mint;
|
||||||
|
only its hash is stored.
|
||||||
|
|
||||||
|
# create a client
|
||||||
|
python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com]
|
||||||
|
|
||||||
|
# attach a project to a client (sets Project.client_id) — by id, number, or name
|
||||||
|
python3 backend/portal_admin.py link-project --slug myler --project-id <PID>
|
||||||
|
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
|
||||||
|
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
|
||||||
|
|
||||||
|
# mint a magic access link (FULL URL PRINTED ONCE — copy it now)
|
||||||
|
python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"]
|
||||||
|
|
||||||
|
# list clients, their projects, and active links
|
||||||
|
python3 backend/portal_admin.py list
|
||||||
|
|
||||||
|
# revoke a link (stops the link AND any live session it minted)
|
||||||
|
python3 backend/portal_admin.py revoke --token-id <TID>
|
||||||
|
|
||||||
|
The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0],
|
||||||
|
# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`.
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from backend.database import SessionLocal
|
||||||
|
from backend.models import Client, ClientAccessToken, Project
|
||||||
|
from backend.portal_auth import hash_token
|
||||||
|
|
||||||
|
PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(db, slug):
|
||||||
|
c = db.query(Client).filter_by(slug=slug).first()
|
||||||
|
if not c:
|
||||||
|
sys.exit(f"No client with slug '{slug}'. Create it first.")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def create_client(args):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if db.query(Client).filter_by(slug=args.slug).first():
|
||||||
|
sys.exit(f"A client with slug '{args.slug}' already exists.")
|
||||||
|
c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug,
|
||||||
|
contact_email=args.email, active=True)
|
||||||
|
db.add(c)
|
||||||
|
db.commit()
|
||||||
|
print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})")
|
||||||
|
print(" Next: link-project, then mint-link.")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def link_project(args):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
c = _get_client(db, args.slug)
|
||||||
|
q = db.query(Project)
|
||||||
|
if args.project_id:
|
||||||
|
p = q.filter_by(id=args.project_id).first()
|
||||||
|
elif args.project_number:
|
||||||
|
p = q.filter_by(project_number=args.project_number).first()
|
||||||
|
elif args.project_name:
|
||||||
|
p = q.filter_by(name=args.project_name).first()
|
||||||
|
else:
|
||||||
|
sys.exit("Specify --project-id, --project-number, or --project-name.")
|
||||||
|
if not p:
|
||||||
|
sys.exit("Project not found.")
|
||||||
|
p.client_id = c.id
|
||||||
|
db.commit()
|
||||||
|
print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def mint_link(args):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
c = _get_client(db, args.slug)
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id,
|
||||||
|
token_hash=hash_token(raw), label=args.label)
|
||||||
|
db.add(tok)
|
||||||
|
db.commit()
|
||||||
|
print(f"✓ Minted access link for '{c.name}'"
|
||||||
|
f"{f' ({args.label})' if args.label else ''} — token id {tok.id}")
|
||||||
|
print("\n COPY THIS NOW (shown only once):\n")
|
||||||
|
print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(args):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first()
|
||||||
|
if not tok:
|
||||||
|
sys.exit("No token with that id.")
|
||||||
|
if tok.revoked_at:
|
||||||
|
print("○ Already revoked.")
|
||||||
|
return
|
||||||
|
tok.revoked_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_all(args):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
clients = db.query(Client).order_by(Client.name).all()
|
||||||
|
if not clients:
|
||||||
|
print("No clients yet.")
|
||||||
|
return
|
||||||
|
for c in clients:
|
||||||
|
state = "" if c.active else " [INACTIVE]"
|
||||||
|
print(f"\n● {c.name} (slug={c.slug}){state}")
|
||||||
|
projs = db.query(Project).filter_by(client_id=c.id).all()
|
||||||
|
print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)"))
|
||||||
|
toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all()
|
||||||
|
if not toks:
|
||||||
|
print(" links: (none — run mint-link)")
|
||||||
|
for t in toks:
|
||||||
|
status = "revoked" if t.revoked_at else "active"
|
||||||
|
last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used"
|
||||||
|
print(f" link {t.id} [{status}] {t.label or ''} (last: {last})")
|
||||||
|
print()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Client-portal admin (M1)")
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("create-client"); p.add_argument("--name", required=True)
|
||||||
|
p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client)
|
||||||
|
|
||||||
|
p = sub.add_parser("link-project"); p.add_argument("--slug", required=True)
|
||||||
|
p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name")
|
||||||
|
p.set_defaults(fn=link_project)
|
||||||
|
|
||||||
|
p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True)
|
||||||
|
p.add_argument("--label"); p.set_defaults(fn=mint_link)
|
||||||
|
|
||||||
|
p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke)
|
||||||
|
|
||||||
|
p = sub.add_parser("list"); p.set_defaults(fn=list_all)
|
||||||
|
|
||||||
|
args = ap.parse_args()
|
||||||
|
args.fn(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Client-portal auth — the swappable gate (see docs/CLIENT_PORTAL.md).
|
||||||
|
|
||||||
|
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
|
||||||
|
mints a signed session cookie. Every portal route depends on get_current_client();
|
||||||
|
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
|
||||||
|
|
||||||
|
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
|
||||||
|
against the DB on every request, so revoking a link (revoked_at) kills its live
|
||||||
|
sessions on the next request — not just future clicks.
|
||||||
|
|
||||||
|
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import Request, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import Client, ClientAccessToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Signing secret for portal session cookies. MUST be set to a real secret in prod
|
||||||
|
# (env). The insecure default only exists so dev/test boots without config.
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
|
||||||
|
if SECRET_KEY == "dev-insecure-change-me":
|
||||||
|
logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")
|
||||||
|
|
||||||
|
COOKIE_NAME = "portal_session"
|
||||||
|
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
||||||
|
|
||||||
|
# Plain, no-token portal links (/portal/open/{project_id}). These are an
|
||||||
|
# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's
|
||||||
|
# open link grants the *whole* client's scope), so they default OFF and must be
|
||||||
|
# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env.
|
||||||
|
PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes")
|
||||||
|
if PORTAL_OPEN_LINKS:
|
||||||
|
logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
|
||||||
|
"Keep this OFF in any internet-facing / production deployment.")
|
||||||
|
|
||||||
|
|
||||||
|
class PortalAuthError(Exception):
|
||||||
|
"""Raised by get_current_client when there's no valid portal session.
|
||||||
|
Handled centrally in main.py: HTML routes get the access-required page,
|
||||||
|
/portal/api/* routes get a 401 JSON."""
|
||||||
|
|
||||||
|
|
||||||
|
# -- token + cookie primitives ----------------------------------------------
|
||||||
|
|
||||||
|
def hash_token(raw: str) -> str:
|
||||||
|
"""sha256 hex of a raw access-token secret (what we store + look up by)."""
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(body: str) -> str:
|
||||||
|
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_cookie(token_id: str) -> str:
|
||||||
|
body = base64.urlsafe_b64encode(
|
||||||
|
json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
|
||||||
|
).decode()
|
||||||
|
return f"{body}.{_sign(body)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_session_cookie(value: str):
|
||||||
|
"""Return the token id from a signed cookie, or None if missing/tampered."""
|
||||||
|
try:
|
||||||
|
body, sig = value.rsplit(".", 1)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
if not hmac.compare_digest(sig, _sign(body)):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(base64.urlsafe_b64decode(body.encode()))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
# Server-side expiry: a leaked cookie isn't valid forever (max_age is only a
|
||||||
|
# browser hint). iat is set by make_session_cookie.
|
||||||
|
iat = data.get("iat")
|
||||||
|
if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE:
|
||||||
|
return None
|
||||||
|
return data.get("tid")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -- the dependency every portal route uses ---------------------------------
|
||||||
|
|
||||||
|
def client_from_cookie(cookie_value, db: Session):
|
||||||
|
"""Resolve a Client from a raw session-cookie value, or None. Re-validates the
|
||||||
|
access token against the DB each call, so a revoked link / disabled client
|
||||||
|
drops immediately. Shared by the HTTP dependency and the WebSocket handler
|
||||||
|
(which can't use Request-based Depends)."""
|
||||||
|
token_id = _read_session_cookie(cookie_value) if cookie_value else None
|
||||||
|
if not token_id:
|
||||||
|
return None
|
||||||
|
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
|
||||||
|
if not tok:
|
||||||
|
return None
|
||||||
|
return db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
|
||||||
|
"""Resolve the authenticated client, or raise PortalAuthError."""
|
||||||
|
client = client_from_cookie(request.cookies.get(COOKIE_NAME), db)
|
||||||
|
if client is None:
|
||||||
|
raise PortalAuthError()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_token(raw_token: str, db: Session):
|
||||||
|
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on
|
||||||
|
success, or (None, None). Also stamps last_used_at."""
|
||||||
|
tok = db.query(ClientAccessToken).filter_by(
|
||||||
|
token_hash=hash_token(raw_token), revoked_at=None
|
||||||
|
).first()
|
||||||
|
if not tok:
|
||||||
|
return None, None
|
||||||
|
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
||||||
|
if not client:
|
||||||
|
return None, None
|
||||||
|
tok.last_used_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
return tok, client
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_project_client(project, db) -> Client:
|
||||||
|
"""Find or create the Client for a project. Reuses the project's linked client
|
||||||
|
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
|
||||||
|
sets project.client_id (only when unset, so it never clobbers a real link)."""
|
||||||
|
client = None
|
||||||
|
if project.client_id:
|
||||||
|
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
|
||||||
|
if client is None:
|
||||||
|
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
|
||||||
|
client = db.query(Client).filter_by(slug=slug).first()
|
||||||
|
if client is None:
|
||||||
|
client = Client(id=str(uuid.uuid4()),
|
||||||
|
name=(project.client_name or project.name or "Preview"),
|
||||||
|
slug=slug, active=True)
|
||||||
|
db.add(client)
|
||||||
|
db.flush()
|
||||||
|
if not project.client_id:
|
||||||
|
project.client_id = client.id
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def mint_link_token(client, db, label=None) -> str:
|
||||||
|
"""Mint a fresh access token for a client and return the RAW secret (caller
|
||||||
|
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored."""
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
|
||||||
|
token_hash=hash_token(raw), label=label))
|
||||||
|
db.commit()
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def provision_preview_session(project, db) -> str:
|
||||||
|
"""Operator preview shortcut: ensure a Client + access token exist for a project
|
||||||
|
and return a token id to seal into a session cookie (no shared link). Reuses an
|
||||||
|
existing token so repeat previews don't accumulate clutter; the raw secret is
|
||||||
|
discarded (preview rides the cookie)."""
|
||||||
|
client = ensure_project_client(project, db)
|
||||||
|
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
|
||||||
|
if tok is None:
|
||||||
|
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
|
||||||
|
token_hash=hash_token(secrets.token_urlsafe(32)),
|
||||||
|
label="preview")
|
||||||
|
db.add(tok)
|
||||||
|
db.commit()
|
||||||
|
return tok.id
|
||||||
@@ -9,6 +9,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
days = int(hours_ago / 24)
|
days = int(hours_ago / 24)
|
||||||
time_ago = f"{days}d ago"
|
time_ago = f"{days}d ago"
|
||||||
|
|
||||||
|
loc = get_active_location(db, emitter.id) if roster_unit else None
|
||||||
call_in = {
|
call_in = {
|
||||||
"unit_id": emitter.id,
|
"unit_id": emitter.id,
|
||||||
"last_seen": emitter.last_seen.isoformat(),
|
"last_seen": emitter.last_seen.isoformat(),
|
||||||
@@ -148,7 +150,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
||||||
"deployed": roster_unit.deployed if roster_unit else False,
|
"deployed": roster_unit.deployed if roster_unit else False,
|
||||||
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
||||||
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
||||||
}
|
}
|
||||||
call_ins.append(call_in)
|
call_ins.append(call_in)
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ def admin_sfm_page(request: Request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/events", response_class=HTMLResponse)
|
||||||
|
def admin_events_page(request: Request):
|
||||||
|
"""SFM Event DB Manager — browse, flag, and delete events across all units."""
|
||||||
|
return templates.TemplateResponse("admin_events.html", {
|
||||||
|
"request": request,
|
||||||
|
"sfm_base_url": SFM_BASE_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/sfm/overview")
|
@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.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Calibration Sync Router
|
||||||
|
|
||||||
|
Endpoints for triggering and inspecting the SFM-driven calibration sync.
|
||||||
|
The scheduled job runs daily; this router is what the "Sync now" button in
|
||||||
|
Settings calls, plus a status endpoint for diagnostics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from backend.services.calibration_sync import (
|
||||||
|
sync_all_calibrations,
|
||||||
|
get_calibration_sync_scheduler,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/calibration", tags=["calibration"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync")
|
||||||
|
async def trigger_calibration_sync() -> Dict[str, Any]:
|
||||||
|
"""Run a full calibration sync now and return the summary."""
|
||||||
|
summary = await sync_all_calibrations()
|
||||||
|
get_calibration_sync_scheduler().last_run = summary
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync/status")
|
||||||
|
def calibration_sync_status() -> Dict[str, Any]:
|
||||||
|
"""Return scheduler status and the most recent run's summary."""
|
||||||
|
return get_calibration_sync_scheduler().status()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Fleet-wide deployment-history calendar — Phase 2 of the
|
||||||
|
deployment-history visualisation work (Phase 1 is the per-unit Gantt
|
||||||
|
on /unit/{id}).
|
||||||
|
|
||||||
|
Renders all UnitAssignment windows across all projects on a 12-month
|
||||||
|
calendar grid styled like the Job Planner. Each day cell shows one
|
||||||
|
mini-bar per project that had ≥1 active assignment that day. Click a
|
||||||
|
day → side panel with the (unit, location) pairs active.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /tools/deployment-history — HTML page
|
||||||
|
GET /api/admin/deployment-history/day — JSON list of deployments
|
||||||
|
on a specific date (used
|
||||||
|
by the day-detail panel)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services.deployment_history import (
|
||||||
|
get_deployment_history_data,
|
||||||
|
get_deployments_on_day,
|
||||||
|
)
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools/deployment-history", response_class=HTMLResponse)
|
||||||
|
def deployment_history_page(
|
||||||
|
request: Request,
|
||||||
|
year: Optional[int] = Query(None),
|
||||||
|
month: Optional[int] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Fleet-wide deployment history calendar.
|
||||||
|
|
||||||
|
Defaults to a 12-month window ending in the current month (so the
|
||||||
|
operator sees the recent past, not the future). ?year=&month= can
|
||||||
|
override the START of the window to scroll backward or forward.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
# Default: 12-month window ending this month → start = 11 months back.
|
||||||
|
if year is None or month is None:
|
||||||
|
# 11 months back from current month.
|
||||||
|
m = today.month - 11
|
||||||
|
y = today.year
|
||||||
|
while m < 1:
|
||||||
|
m += 12
|
||||||
|
y -= 1
|
||||||
|
start_year, start_month = y, m
|
||||||
|
else:
|
||||||
|
start_year, start_month = year, month
|
||||||
|
|
||||||
|
calendar = get_deployment_history_data(db, start_year, start_month)
|
||||||
|
|
||||||
|
# Build prev/next navigation values.
|
||||||
|
prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1)
|
||||||
|
next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("admin/deployment_history.html", {
|
||||||
|
"request": request,
|
||||||
|
"calendar": calendar,
|
||||||
|
"today": today.isoformat(),
|
||||||
|
"prev_year": prev_y,
|
||||||
|
"prev_month": prev_m,
|
||||||
|
"next_year": next_y,
|
||||||
|
"next_month": next_m,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/deployment-history/day")
|
||||||
|
def deployment_history_day(
|
||||||
|
target_date: str = Query(..., description="YYYY-MM-DD"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return assignments active on a specific calendar day."""
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(target_date)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": f"Invalid date: {target_date!r}"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
deployments = get_deployments_on_day(db, d)
|
||||||
|
return JSONResponse({
|
||||||
|
"date": target_date,
|
||||||
|
"count": len(deployments),
|
||||||
|
"deployments": deployments,
|
||||||
|
})
|
||||||
@@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
# Last seen from emitter
|
# Last seen from emitter
|
||||||
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||||
|
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
|
loc = get_active_location(db, u.id)
|
||||||
return {
|
return {
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"unit_type": u.unit_type,
|
"unit_type": u.unit_type,
|
||||||
"deployed": u.deployed,
|
"deployed": u.deployed,
|
||||||
"out_for_calibration": u.out_for_calibration or False,
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
"note": u.note or "",
|
"note": u.note or "",
|
||||||
"project_id": u.project_id or "",
|
"project_id": (loc or {}).get("project_id") or u.project_id or "",
|
||||||
"address": u.address or u.location or "",
|
"address": (loc or {}).get("address") or "",
|
||||||
"coordinates": u.coordinates or "",
|
"coordinates": (loc or {}).get("coordinates") or "",
|
||||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||||
|
|||||||
@@ -361,6 +361,9 @@ def locations_search(
|
|||||||
db.query(MonitoringLocation)
|
db.query(MonitoringLocation)
|
||||||
.filter(MonitoringLocation.project_id == project_id)
|
.filter(MonitoringLocation.project_id == project_id)
|
||||||
.filter(MonitoringLocation.location_type == "vibration")
|
.filter(MonitoringLocation.location_type == "vibration")
|
||||||
|
# Don't propose creating assignments at removed locations — they
|
||||||
|
# were intentionally decommissioned and shouldn't be backfill targets.
|
||||||
|
.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -373,7 +376,11 @@ def locations_search(
|
|||||||
if q_norm in l_norm:
|
if q_norm in l_norm:
|
||||||
scored.append((l, 1.0))
|
scored.append((l, 1.0))
|
||||||
continue
|
continue
|
||||||
score = svc.similarity(q_norm, l_norm)
|
# Use the location-specific scorer (token_set_ratio + multi-digit
|
||||||
|
# penalty) instead of WRatio — same reason as the cluster-match
|
||||||
|
# path: location names share too much boilerplate vocabulary for
|
||||||
|
# WRatio to discriminate reliably.
|
||||||
|
score = svc.location_similarity(q_norm, l_norm)
|
||||||
if score >= 0.50:
|
if score >= 0.50:
|
||||||
scored.append((l, score))
|
scored.append((l, score))
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -85,8 +86,7 @@ async def get_modem_units(
|
|||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.ip_address.ilike(search_term)) |
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
(RosterUnit.hardware_model.ilike(search_term)) |
|
(RosterUnit.hardware_model.ilike(search_term)) |
|
||||||
(RosterUnit.phone_number.ilike(search_term)) |
|
(RosterUnit.phone_number.ilike(search_term))
|
||||||
(RosterUnit.location.ilike(search_term))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
modems = query.order_by(
|
modems = query.order_by(
|
||||||
@@ -128,6 +128,8 @@ async def get_modem_units(
|
|||||||
if filter_status and status != filter_status:
|
if filter_status and status != filter_status:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Inherit location from the paired device's active assignment.
|
||||||
|
loc = get_active_location(db, modem.id) if paired else None
|
||||||
modem_list.append({
|
modem_list.append({
|
||||||
"id": modem.id,
|
"id": modem.id,
|
||||||
"ip_address": modem.ip_address,
|
"ip_address": modem.ip_address,
|
||||||
@@ -135,8 +137,8 @@ async def get_modem_units(
|
|||||||
"hardware_model": modem.hardware_model,
|
"hardware_model": modem.hardware_model,
|
||||||
"deployed": modem.deployed,
|
"deployed": modem.deployed,
|
||||||
"retired": modem.retired,
|
"retired": modem.retired,
|
||||||
"location": modem.location,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"project_id": modem.project_id,
|
"project_id": (loc or {}).get("project_id") or modem.project_id,
|
||||||
"paired_device": paired,
|
"paired_device": paired,
|
||||||
"status": status
|
"status": status
|
||||||
})
|
})
|
||||||
@@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if device:
|
if device:
|
||||||
|
loc = get_active_location(db, device.id)
|
||||||
return {
|
return {
|
||||||
"paired": True,
|
"paired": True,
|
||||||
"device": {
|
"device": {
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": device.project_id,
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||||
"location": device.location or device.address
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,8 +317,6 @@ async def get_pairable_devices(
|
|||||||
query = query.filter(
|
query = query.filter(
|
||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.project_id.ilike(search_term)) |
|
(RosterUnit.project_id.ilike(search_term)) |
|
||||||
(RosterUnit.location.ilike(search_term)) |
|
|
||||||
(RosterUnit.address.ilike(search_term)) |
|
|
||||||
(RosterUnit.note.ilike(search_term))
|
(RosterUnit.note.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,12 +339,13 @@ async def get_pairable_devices(
|
|||||||
if hide_paired and is_paired_to_other:
|
if hide_paired and is_paired_to_other:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
loc = get_active_location(db, device.id)
|
||||||
device_list.append({
|
device_list.append({
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": device.project_id,
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||||
"location": device.location or device.address,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"note": device.note,
|
"note": device.note,
|
||||||
"paired_modem_id": device.deployed_with_modem_id,
|
"paired_modem_id": device.deployed_with_modem_id,
|
||||||
"is_paired_to_this": is_paired_to_this,
|
"is_paired_to_this": is_paired_to_this,
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
"""
|
||||||
|
Pending deployments — field-captured "I just installed this seismograph"
|
||||||
|
records waiting to be classified into a project + location.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
POST /api/deployments/capture — capture a new pending deployment
|
||||||
|
GET /api/deployments/pending — list awaiting captures
|
||||||
|
GET /api/deployments/pending/{id} — single capture detail
|
||||||
|
POST /api/deployments/pending/{id}/promote — classify → create UnitAssignment
|
||||||
|
POST /api/deployments/pending/{id}/cancel — abandon
|
||||||
|
|
||||||
|
See backend/models.py PendingDeployment docstring for the full lifecycle.
|
||||||
|
|
||||||
|
Seismograph-only for v1; capture refuses if unit_id is anything else.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import (
|
||||||
|
PendingDeployment,
|
||||||
|
RosterUnit,
|
||||||
|
Project,
|
||||||
|
MonitoringLocation,
|
||||||
|
UnitAssignment,
|
||||||
|
UnitHistory,
|
||||||
|
)
|
||||||
|
from backend.routers.photos import extract_exif_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/deployments", tags=["pending-deployments"])
|
||||||
|
|
||||||
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_history(
|
||||||
|
db: Session,
|
||||||
|
unit_id: str,
|
||||||
|
change_type: str,
|
||||||
|
*,
|
||||||
|
old_value: Optional[str] = None,
|
||||||
|
new_value: Optional[str] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
source: str = "manual",
|
||||||
|
) -> None:
|
||||||
|
"""Mirror of project_locations._record_assignment_history — kept local
|
||||||
|
so this router doesn't depend on a project_locations import cycle."""
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type=change_type,
|
||||||
|
field_name="pending_deployment",
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
changed_at=datetime.utcnow(),
|
||||||
|
source=source,
|
||||||
|
notes=notes,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/seismograph-picker")
|
||||||
|
def seismograph_picker(
|
||||||
|
q: str = "",
|
||||||
|
limit: int = 20,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""JSON list of seismograph units for the /deploy mobile picker.
|
||||||
|
|
||||||
|
Filters out retired units. Sorts by recency of pending captures
|
||||||
|
first, then alphabetically — so units the operator is actively
|
||||||
|
deploying with surface at the top.
|
||||||
|
"""
|
||||||
|
q_clean = (q or "").strip()
|
||||||
|
qb = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.device_type == "seismograph",
|
||||||
|
RosterUnit.retired == False, # noqa: E712
|
||||||
|
)
|
||||||
|
if q_clean:
|
||||||
|
qb = qb.filter(
|
||||||
|
(RosterUnit.id.ilike(f"%{q_clean}%"))
|
||||||
|
| (RosterUnit.note.ilike(f"%{q_clean}%"))
|
||||||
|
)
|
||||||
|
units = qb.order_by(RosterUnit.id).limit(limit).all()
|
||||||
|
|
||||||
|
# Annotate with "has an awaiting pending deployment" so the picker
|
||||||
|
# can de-emphasize / warn on units that are already mid-deploy.
|
||||||
|
pending_unit_ids = {
|
||||||
|
r[0] for r in db.query(PendingDeployment.unit_id)
|
||||||
|
.filter_by(status="awaiting").distinct().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"id": u.id,
|
||||||
|
"note": u.note,
|
||||||
|
"deployed": u.deployed,
|
||||||
|
"has_pending": u.id in pending_unit_ids,
|
||||||
|
}
|
||||||
|
for u in units
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/capture")
|
||||||
|
async def capture_deployment(
|
||||||
|
unit_id: str = Form(...),
|
||||||
|
operator_note: str = Form(""),
|
||||||
|
captured_at_iso: str = Form(""),
|
||||||
|
photo: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Field-capture endpoint.
|
||||||
|
|
||||||
|
Multipart form:
|
||||||
|
unit_id — seismograph being deployed
|
||||||
|
operator_note — optional free-text site memo
|
||||||
|
captured_at_iso — optional override of the capture timestamp
|
||||||
|
(default: photo's EXIF DateTimeOriginal, or now)
|
||||||
|
photo — install photo (EXIF GPS extracted if present)
|
||||||
|
|
||||||
|
Refuses if unit_id isn't a seismograph (SLM deployments don't follow
|
||||||
|
the same field-install pattern).
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id!r} not found.")
|
||||||
|
if unit.device_type != "seismograph":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Pending deployments are for seismographs only "
|
||||||
|
f"(this unit is {unit.device_type}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate + save the photo.
|
||||||
|
file_ext = Path(photo.filename or "photo.jpg").suffix.lower()
|
||||||
|
if file_ext not in _ALLOWED_PHOTO_EXTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid photo type {file_ext!r}. Allowed: {sorted(_ALLOWED_PHOTO_EXTS)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||||
|
unit_photo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
capture_id = str(uuid.uuid4())
|
||||||
|
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"install_{ts_str}_{capture_id[:8]}{file_ext}"
|
||||||
|
file_path = unit_photo_dir / filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "wb") as buf:
|
||||||
|
shutil.copyfileobj(photo.file, buf)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save photo: {e}")
|
||||||
|
|
||||||
|
# Extract EXIF — best-effort. No EXIF / no GPS is fine; operator
|
||||||
|
# can fill coordinates manually later in the promote step.
|
||||||
|
metadata = extract_exif_data(file_path)
|
||||||
|
coords = metadata.get("coordinates") # "lat,lon" or None
|
||||||
|
photo_ts = metadata.get("timestamp") # datetime or None
|
||||||
|
|
||||||
|
if captured_at_iso:
|
||||||
|
try:
|
||||||
|
captured_at = datetime.fromisoformat(captured_at_iso)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid captured_at_iso: {captured_at_iso!r}")
|
||||||
|
elif photo_ts:
|
||||||
|
captured_at = photo_ts
|
||||||
|
else:
|
||||||
|
captured_at = datetime.utcnow()
|
||||||
|
|
||||||
|
pd = PendingDeployment(
|
||||||
|
id = capture_id,
|
||||||
|
unit_id = unit_id,
|
||||||
|
captured_at = captured_at,
|
||||||
|
coordinates = coords,
|
||||||
|
operator_note = (operator_note or "").strip() or None,
|
||||||
|
photo_filename = filename,
|
||||||
|
status = "awaiting",
|
||||||
|
)
|
||||||
|
db.add(pd)
|
||||||
|
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=unit_id,
|
||||||
|
change_type="pending_deployment_captured",
|
||||||
|
new_value=f"awaiting classification @ {captured_at:%Y-%m-%d %H:%M}"
|
||||||
|
+ (f" • {coords}" if coords else ""),
|
||||||
|
notes=(operator_note or None),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pd)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"pending_deployment": _to_dict(pd, unit=unit),
|
||||||
|
"photo_url": f"/api/unit/{unit_id}/photo/{filename}",
|
||||||
|
"extracted_coords": coords,
|
||||||
|
"extracted_timestamp": photo_ts.isoformat() if photo_ts else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
def list_pending(
|
||||||
|
status: str = "awaiting",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List pending deployments by status (default: awaiting classification)."""
|
||||||
|
rows = (
|
||||||
|
db.query(PendingDeployment)
|
||||||
|
.filter_by(status=status)
|
||||||
|
.order_by(PendingDeployment.captured_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# Bulk-resolve unit references in one query (avoid N+1).
|
||||||
|
unit_ids = {r.unit_id for r in rows}
|
||||||
|
units = {u.id: u for u in db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()} \
|
||||||
|
if unit_ids else {}
|
||||||
|
return {
|
||||||
|
"count": len(rows),
|
||||||
|
"status": status,
|
||||||
|
"pending_deployments": [_to_dict(r, unit=units.get(r.unit_id)) for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending/{pending_id}")
|
||||||
|
def get_pending(pending_id: str, db: Session = Depends(get_db)):
|
||||||
|
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
|
||||||
|
if not pd:
|
||||||
|
raise HTTPException(status_code=404, detail="Pending deployment not found.")
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
|
||||||
|
return _to_dict(pd, unit=unit, detail=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/{pending_id}/promote")
|
||||||
|
async def promote_pending(
|
||||||
|
pending_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Classify a pending deployment → create a UnitAssignment.
|
||||||
|
|
||||||
|
Body JSON — one of two shapes:
|
||||||
|
|
||||||
|
1. Assign to existing location:
|
||||||
|
{
|
||||||
|
"location_id": "<uuid>",
|
||||||
|
"notes": "<optional>"
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Create a new location under (existing or new) project:
|
||||||
|
{
|
||||||
|
"project_id": "<existing>" | null, # null means create new
|
||||||
|
"project_name": "<required if project_id is null>",
|
||||||
|
"project_type_id": "<existing project_type id, e.g. 'vibration_monitoring'>",
|
||||||
|
# required if creating new project
|
||||||
|
"location_name": "<required>",
|
||||||
|
"use_captured_coords": true | false, # default true — write the
|
||||||
|
# pending's coordinates onto
|
||||||
|
# the new location
|
||||||
|
"notes": "<optional>"
|
||||||
|
}
|
||||||
|
|
||||||
|
Status flips to "assigned"; resulting_assignment_id is populated.
|
||||||
|
"""
|
||||||
|
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
|
||||||
|
if not pd:
|
||||||
|
raise HTTPException(status_code=404, detail="Pending deployment not found.")
|
||||||
|
if pd.status != "awaiting":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Pending deployment is {pd.status!r}, not awaiting — already classified?",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body.")
|
||||||
|
|
||||||
|
notes = (payload.get("notes") or "").strip() or None
|
||||||
|
|
||||||
|
# Resolve / create the location.
|
||||||
|
location_id = payload.get("location_id")
|
||||||
|
if location_id:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.")
|
||||||
|
project_id = location.project_id
|
||||||
|
else:
|
||||||
|
# Create-new path. Need a project (existing or new).
|
||||||
|
project_id = payload.get("project_id")
|
||||||
|
if not project_id:
|
||||||
|
project_name = (payload.get("project_name") or "").strip()
|
||||||
|
project_type_id = (payload.get("project_type_id") or "").strip()
|
||||||
|
if not project_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Either project_id, or project_name + project_type_id, required.",
|
||||||
|
)
|
||||||
|
if not project_type_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="project_type_id required when creating a new project.",
|
||||||
|
)
|
||||||
|
new_project = Project(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=project_name,
|
||||||
|
project_type_id=project_type_id,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(new_project)
|
||||||
|
db.flush()
|
||||||
|
project_id = new_project.id
|
||||||
|
|
||||||
|
loc_name = (payload.get("location_name") or "").strip()
|
||||||
|
if not loc_name:
|
||||||
|
raise HTTPException(status_code=400, detail="location_name required.")
|
||||||
|
use_coords = payload.get("use_captured_coords", True)
|
||||||
|
location = MonitoringLocation(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=project_id,
|
||||||
|
location_type="vibration", # seismographs only
|
||||||
|
name=loc_name,
|
||||||
|
coordinates=(pd.coordinates if use_coords else None),
|
||||||
|
)
|
||||||
|
db.add(location)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# If this location already has an active assignment, the /deploy
|
||||||
|
# capture means someone replaced that unit in the field — close the
|
||||||
|
# old assignment, break the outgoing unit's modem pairing, and bench
|
||||||
|
# it so the heartbeat / polling subsystem stops chasing it.
|
||||||
|
existing_active = db.query(UnitAssignment).filter(
|
||||||
|
UnitAssignment.location_id == location.id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
).first()
|
||||||
|
if existing_active and existing_active.unit_id != pd.unit_id:
|
||||||
|
existing_active.assigned_until = pd.captured_at
|
||||||
|
existing_active.status = "completed"
|
||||||
|
old_unit = db.query(RosterUnit).filter_by(id=existing_active.unit_id).first()
|
||||||
|
if old_unit:
|
||||||
|
if old_unit.deployed_with_modem_id:
|
||||||
|
old_modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=old_unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if old_modem and old_modem.deployed_with_unit_id == old_unit.id:
|
||||||
|
old_modem.deployed_with_unit_id = None
|
||||||
|
old_unit.deployed_with_modem_id = None
|
||||||
|
if old_unit.deployed:
|
||||||
|
old_unit.deployed = False
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=existing_active.unit_id,
|
||||||
|
change_type="assignment_swapped",
|
||||||
|
old_value=location.name,
|
||||||
|
new_value=f"superseded by /deploy capture → {pd.unit_id}",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the assignment. assigned_at = pending capture time (so
|
||||||
|
# events emitted after the install are correctly attributed back
|
||||||
|
# to this location).
|
||||||
|
assignment = UnitAssignment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=pd.unit_id,
|
||||||
|
location_id=location.id,
|
||||||
|
project_id=project_id,
|
||||||
|
device_type="seismograph",
|
||||||
|
assigned_at=pd.captured_at,
|
||||||
|
assigned_until=None,
|
||||||
|
status="active",
|
||||||
|
notes=notes,
|
||||||
|
source="manual",
|
||||||
|
)
|
||||||
|
db.add(assignment)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Incoming unit is in the field again — flip it back to deployed
|
||||||
|
# if it was on the bench (mirrors the swap endpoint).
|
||||||
|
incoming_unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
|
||||||
|
if incoming_unit and not incoming_unit.deployed:
|
||||||
|
incoming_unit.deployed = True
|
||||||
|
|
||||||
|
# Promote the pending row.
|
||||||
|
pd.status = "assigned"
|
||||||
|
pd.promoted_at = datetime.utcnow()
|
||||||
|
pd.resulting_assignment_id = assignment.id
|
||||||
|
pd.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=pd.unit_id,
|
||||||
|
change_type="pending_deployment_promoted",
|
||||||
|
old_value="awaiting",
|
||||||
|
new_value=f"{location.name} (assignment {assignment.id[:8]})",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pd)
|
||||||
|
db.refresh(assignment)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"assignment_id": assignment.id,
|
||||||
|
"location_id": location.id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"promoted_at": pd.promoted_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/{pending_id}/cancel")
|
||||||
|
async def cancel_pending(
|
||||||
|
pending_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Mark a pending deployment as cancelled (operator captured by mistake)."""
|
||||||
|
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
|
||||||
|
if not pd:
|
||||||
|
raise HTTPException(status_code=404, detail="Pending deployment not found.")
|
||||||
|
if pd.status != "awaiting":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Pending deployment is {pd.status!r}, not awaiting.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
reason = (payload.get("reason") or "").strip() or None
|
||||||
|
|
||||||
|
pd.status = "cancelled"
|
||||||
|
pd.cancelled_at = datetime.utcnow()
|
||||||
|
pd.cancelled_reason = reason
|
||||||
|
pd.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=pd.unit_id,
|
||||||
|
change_type="pending_deployment_cancelled",
|
||||||
|
old_value="awaiting",
|
||||||
|
new_value="cancelled",
|
||||||
|
notes=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict:
|
||||||
|
out = {
|
||||||
|
"id": pd.id,
|
||||||
|
"unit_id": pd.unit_id,
|
||||||
|
"captured_at": pd.captured_at.isoformat() if pd.captured_at else None,
|
||||||
|
"coordinates": pd.coordinates,
|
||||||
|
"operator_note": pd.operator_note,
|
||||||
|
"photo_filename": pd.photo_filename,
|
||||||
|
"photo_url": f"/api/unit/{pd.unit_id}/photo/{pd.photo_filename}"
|
||||||
|
if pd.photo_filename else None,
|
||||||
|
"status": pd.status,
|
||||||
|
"created_at": pd.created_at.isoformat() if pd.created_at else None,
|
||||||
|
}
|
||||||
|
if pd.status == "assigned":
|
||||||
|
out["promoted_at"] = pd.promoted_at.isoformat() if pd.promoted_at else None
|
||||||
|
out["resulting_assignment_id"] = pd.resulting_assignment_id
|
||||||
|
if pd.status == "cancelled":
|
||||||
|
out["cancelled_at"] = pd.cancelled_at.isoformat() if pd.cancelled_at else None
|
||||||
|
out["cancelled_reason"] = pd.cancelled_reason
|
||||||
|
|
||||||
|
if unit:
|
||||||
|
out["unit"] = {
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type,
|
||||||
|
"note": unit.note,
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
}
|
||||||
|
return out
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
||||||
|
|
||||||
|
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
|
||||||
|
session cookie, then sees their locations (overview) and per-location read-only
|
||||||
|
live data sourced from SLMM's cache. Every data route re-checks ownership.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db, SessionLocal
|
||||||
|
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
|
||||||
|
from backend.templates_config import templates
|
||||||
|
from backend.portal_auth import (
|
||||||
|
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
|
||||||
|
provision_preview_session, PORTAL_OPEN_LINKS,
|
||||||
|
COOKIE_NAME, COOKIE_MAX_AGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/portal", tags=["portal"])
|
||||||
|
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||||
|
|
||||||
|
# Whitelist of fields the portal exposes to a client — sound metrics + run state
|
||||||
|
# only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed.
|
||||||
|
_PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time",
|
||||||
|
"lp", "leq", "lmax", "lpeak", "ln1", "ln2")
|
||||||
|
|
||||||
|
|
||||||
|
# -- scoping (every data route gates through these) --------------------------
|
||||||
|
|
||||||
|
def _client_project_ids(client: Client, db: Session) -> list:
|
||||||
|
return [r[0] for r in db.query(Project.id).filter(
|
||||||
|
Project.client_id == client.id, Project.status != "deleted").all()]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation:
|
||||||
|
"""Ownership gate: location must be a sound location in one of the client's
|
||||||
|
active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so
|
||||||
|
we never leak whether a location exists."""
|
||||||
|
loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first()
|
||||||
|
if (not loc or loc.location_type != "sound"
|
||||||
|
or loc.project_id not in _client_project_ids(client, db)):
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
def active_unit_for_location(location_id: str, db: Session):
|
||||||
|
"""The SLM unit currently assigned to this location, or None."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
asg = (db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.location_id == location_id,
|
||||||
|
UnitAssignment.status == "active",
|
||||||
|
UnitAssignment.device_type == "slm",
|
||||||
|
or_(UnitAssignment.assigned_until.is_(None),
|
||||||
|
UnitAssignment.assigned_until > now))
|
||||||
|
.order_by(UnitAssignment.assigned_at.desc()).first())
|
||||||
|
return asg.unit_id if asg else None
|
||||||
|
|
||||||
|
|
||||||
|
def _client_locations(client: Client, db: Session) -> list:
|
||||||
|
"""The client's active sound locations (for the overview tiles + map)."""
|
||||||
|
pids = _client_project_ids(client, db)
|
||||||
|
if not pids:
|
||||||
|
return []
|
||||||
|
projs = {p.id: p.name for p in
|
||||||
|
db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()}
|
||||||
|
locs = (db.query(MonitoringLocation)
|
||||||
|
.filter(MonitoringLocation.project_id.in_(pids),
|
||||||
|
MonitoringLocation.location_type == "sound",
|
||||||
|
MonitoringLocation.removed_at.is_(None))
|
||||||
|
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all())
|
||||||
|
return [{
|
||||||
|
"id": loc.id, "name": loc.name,
|
||||||
|
"address": loc.address, "coordinates": loc.coordinates,
|
||||||
|
"project_name": projs.get(loc.project_id),
|
||||||
|
"has_device": active_unit_for_location(loc.id, db) is not None,
|
||||||
|
} for loc in locs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enter/{token}")
|
||||||
|
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
|
||||||
|
tok, client = resolve_token(token, db)
|
||||||
|
if not client:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/access_required.html",
|
||||||
|
{"request": request, "reason": "invalid"},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
resp = RedirectResponse(url="/portal", status_code=303)
|
||||||
|
resp.set_cookie(
|
||||||
|
COOKIE_NAME, make_session_cookie(tok.id),
|
||||||
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
|
||||||
|
)
|
||||||
|
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/open/{project_id}")
|
||||||
|
def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Dev-only plain shareable link: open a project's client portal with no token
|
||||||
|
(gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback —
|
||||||
|
sets the session cookie and lands on /portal. Lives under /portal so it works
|
||||||
|
through a reverse proxy that exposes only /portal/*."""
|
||||||
|
if not PORTAL_OPEN_LINKS:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/access_required.html", {"request": request, "reason": "required"},
|
||||||
|
status_code=404)
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
||||||
|
status_code=404)
|
||||||
|
token_id = provision_preview_session(project, db)
|
||||||
|
resp = RedirectResponse(url="/portal", status_code=303)
|
||||||
|
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
||||||
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
def portal_logout():
|
||||||
|
resp = RedirectResponse(url="/portal/access", status_code=303)
|
||||||
|
resp.delete_cookie(COOKIE_NAME)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/access")
|
||||||
|
def portal_access(request: Request):
|
||||||
|
"""Landing for an unauthenticated visitor (no valid link)."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/access_required.html", {"request": request, "reason": "required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def portal_home(request: Request, client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Client overview — their active sound locations with live tiles + a map."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/overview.html",
|
||||||
|
{"request": request, "client": client,
|
||||||
|
"locations": _client_locations(client, db)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/location/{location_id}")
|
||||||
|
def portal_location(location_id: str, request: Request,
|
||||||
|
client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Read-only live view for one of the client's locations (404 if not owned)."""
|
||||||
|
loc = resolve_client_location(client, location_id, db)
|
||||||
|
return templates.TemplateResponse("portal/location.html", {
|
||||||
|
"request": request, "client": client, "location": loc,
|
||||||
|
"has_device": active_unit_for_location(location_id, db) is not None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# -- scoped data (cache reads only — never hits the device) ------------------
|
||||||
|
|
||||||
|
@router.get("/api/location/{location_id}/live")
|
||||||
|
async def portal_location_live(location_id: str,
|
||||||
|
client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Scrubbed cached live reading for a location the client owns."""
|
||||||
|
resolve_client_location(client, location_id, db)
|
||||||
|
unit_id = active_unit_for_location(location_id, db)
|
||||||
|
if not unit_id:
|
||||||
|
return {"status": "ok", "data": None, "reason": "no_device"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
||||||
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||||
|
except Exception:
|
||||||
|
return {"status": "ok", "data": None, "reason": "unreachable"}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"status": "ok", "data": None, "reason": "no_data"}
|
||||||
|
full = (r.json() or {}).get("data", {}) or {}
|
||||||
|
return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/location/{location_id}/history")
|
||||||
|
async def portal_location_history(location_id: str, hours: float = 2.0,
|
||||||
|
client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Cached chart trail for a location the client owns. (Trail rows are already
|
||||||
|
just timestamp + lp/leq/lmax/ln1/ln2 — safe to pass through.)"""
|
||||||
|
resolve_client_location(client, location_id, db)
|
||||||
|
unit_id = active_unit_for_location(location_id, db)
|
||||||
|
if not unit_id:
|
||||||
|
return {"status": "ok", "readings": []}
|
||||||
|
hours = max(0.1, min(hours, 48.0))
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
||||||
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history",
|
||||||
|
params={"hours": hours})
|
||||||
|
except Exception:
|
||||||
|
return {"status": "ok", "readings": []}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"status": "ok", "readings": []}
|
||||||
|
raw = (r.json() or {}).get("readings", [])
|
||||||
|
fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints
|
||||||
|
return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]}
|
||||||
|
|
||||||
|
|
||||||
|
# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
|
||||||
|
_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
|
||||||
|
"onset_value", "peak_value", "clear_at", "status")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/location/{location_id}/events")
|
||||||
|
async def portal_location_events(location_id: str, limit: int = 20,
|
||||||
|
client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Scrubbed breach history for a location the client owns (read-only)."""
|
||||||
|
resolve_client_location(client, location_id, db)
|
||||||
|
unit_id = active_unit_for_location(location_id, db)
|
||||||
|
if not unit_id:
|
||||||
|
return {"status": "ok", "events": []}
|
||||||
|
limit = max(1, min(limit, 100))
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
||||||
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
|
||||||
|
params={"limit": limit})
|
||||||
|
except Exception:
|
||||||
|
return {"status": "ok", "events": []}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"status": "ok", "events": []}
|
||||||
|
raw = (r.json() or {}).get("events", [])
|
||||||
|
events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
|
||||||
|
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
|
||||||
|
|
||||||
|
|
||||||
|
# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/
|
||||||
|
# hysteresis internals).
|
||||||
|
_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s",
|
||||||
|
"schedule_start", "schedule_end", "schedule_days")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/location/{location_id}/thresholds")
|
||||||
|
async def portal_location_thresholds(location_id: str,
|
||||||
|
client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""The active alert limits for a location the client owns (enabled rules only),
|
||||||
|
so the client can see what they're being alerted on. Read-only, scrubbed."""
|
||||||
|
resolve_client_location(client, location_id, db)
|
||||||
|
unit_id = active_unit_for_location(location_id, db)
|
||||||
|
if not unit_id:
|
||||||
|
return {"status": "ok", "rules": []}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
||||||
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules")
|
||||||
|
except Exception:
|
||||||
|
return {"status": "ok", "rules": []}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"status": "ok", "rules": []}
|
||||||
|
raw = (r.json() or {}).get("rules", [])
|
||||||
|
rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")]
|
||||||
|
return {"status": "ok", "rules": rules}
|
||||||
|
|
||||||
|
|
||||||
|
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
|
||||||
|
|
||||||
|
def _scrub_frame(raw: str):
|
||||||
|
"""Project a monitor frame down to the portal whitelist. Drops internal fields
|
||||||
|
(unit_id, raw_payload, lmin) before it reaches a client; passes control fields
|
||||||
|
(feed_status, heartbeat) + timestamp through. Returns None for a non-JSON frame
|
||||||
|
so the caller drops it rather than forwarding anything unscrubbed."""
|
||||||
|
try:
|
||||||
|
d = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d}
|
||||||
|
if "timestamp" in d:
|
||||||
|
out["timestamp"] = d["timestamp"]
|
||||||
|
for ctrl in ("feed_status", "heartbeat"):
|
||||||
|
if ctrl in d:
|
||||||
|
out[ctrl] = d[ctrl]
|
||||||
|
return json.dumps(out)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/location/{location_id}/stream")
|
||||||
|
async def portal_location_stream(websocket: WebSocket, location_id: str):
|
||||||
|
"""Live ~1Hz feed for a location the client owns. Auths via the session cookie,
|
||||||
|
enforces ownership, then bridges the unit's shared SLMM /monitor fan-out feed
|
||||||
|
to the browser (scrubbed). A viewer is just one more subscriber to the one
|
||||||
|
device feed — no extra device connection."""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Auth + ownership on a short-lived session, then release it for the long bridge.
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
client = client_from_cookie(websocket.cookies.get(COOKIE_NAME), db)
|
||||||
|
if client is None:
|
||||||
|
await websocket.close(code=1008) # policy violation (not authenticated)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resolve_client_location(client, location_id, db)
|
||||||
|
except HTTPException:
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
unit_id = active_unit_for_location(location_id, db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not unit_id:
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"feed_status": "no_device"})
|
||||||
|
finally:
|
||||||
|
await websocket.close(code=1000)
|
||||||
|
return
|
||||||
|
|
||||||
|
target = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
|
||||||
|
backend_ws = None
|
||||||
|
try:
|
||||||
|
backend_ws = await websockets.connect(target)
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
async for message in backend_ws:
|
||||||
|
frame = _scrub_frame(message)
|
||||||
|
if frame is not None:
|
||||||
|
await websocket.send_text(frame)
|
||||||
|
|
||||||
|
async def watch_client():
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
|
||||||
|
tasks = [asyncio.ensure_future(forward_to_client()),
|
||||||
|
asyncio.ensure_future(watch_client())]
|
||||||
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[PORTAL] stream {location_id}: {e}")
|
||||||
|
finally:
|
||||||
|
if backend_ws:
|
||||||
|
try:
|
||||||
|
await backend_ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
+1005
-183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
|||||||
|
"""
|
||||||
|
Nightly Report Router.
|
||||||
|
|
||||||
|
Manual triggers for the night-vs-baseline sound report — the same entry point
|
||||||
|
the scheduled morning tick will reuse. Two endpoints:
|
||||||
|
|
||||||
|
GET …/reports/nightly/view → render and return the HTML inline (preview).
|
||||||
|
No write, no email. Browser-friendly.
|
||||||
|
POST …/reports/nightly/run → full run: build → write report.html/json to
|
||||||
|
disk → (dry-run) email. Returns JSON result.
|
||||||
|
|
||||||
|
Dates are the *evening* date of the night being reported (the 7/7 in "night of
|
||||||
|
7/7 → morning 7/8"). Defaults to last night. Baseline is optional; pass the
|
||||||
|
baseline-week range to populate the comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import Project, SoundReportConfig, MonitoringLocation
|
||||||
|
from backend.services.report_pipeline import (
|
||||||
|
METRIC_REGISTRY, DEFAULT_METRICS, DEFAULT_WINDOWS, _location_reference_baseline,
|
||||||
|
)
|
||||||
|
from backend.services.report_orchestrator import run_nightly_report
|
||||||
|
from backend.utils.timezone import utc_to_local
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/projects/{project_id}/reports", tags=["reports"])
|
||||||
|
|
||||||
|
|
||||||
|
def _default_night_date() -> date:
|
||||||
|
"""Last night = yesterday in the user's local timezone."""
|
||||||
|
return (utc_to_local(datetime.utcnow()) - timedelta(days=1)).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: Optional[str], field: str) -> Optional[date]:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{field} must be YYYY-MM-DD (got {s!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_metrics(s: Optional[str]) -> list[str]:
|
||||||
|
if not s:
|
||||||
|
return list(DEFAULT_METRICS)
|
||||||
|
keys = [k.strip().lower() for k in s.split(",") if k.strip()]
|
||||||
|
unknown = [k for k in keys if k not in METRIC_REGISTRY]
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unknown metric(s): {unknown}. Known: {sorted(METRIC_REGISTRY)}",
|
||||||
|
)
|
||||||
|
return keys or list(DEFAULT_METRICS)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_hhmm(s) -> str:
|
||||||
|
"""Validate a local HH:MM (24h) time string."""
|
||||||
|
try:
|
||||||
|
hh, mm = str(s).split(":")
|
||||||
|
h, m = int(hh), int(mm)
|
||||||
|
if 0 <= h < 24 and 0 <= m < 60:
|
||||||
|
return f"{h:02d}:{m:02d}"
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=400, detail=f"report_time must be HH:MM 24-hour (got {s!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _config_dict(cfg: Optional[SoundReportConfig], project_id: str) -> dict:
|
||||||
|
"""Serialise a config row (or defaults if none yet) to JSON."""
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"exists": cfg is not None,
|
||||||
|
"enabled": cfg.enabled if cfg else False,
|
||||||
|
"report_time": cfg.report_time if cfg else "08:00",
|
||||||
|
"metric_keys": cfg.metric_keys if cfg else ",".join(DEFAULT_METRICS),
|
||||||
|
"baseline_mode": cfg.baseline_mode if cfg else "captured",
|
||||||
|
"baseline_start": cfg.baseline_start.isoformat() if cfg and cfg.baseline_start else None,
|
||||||
|
"baseline_end": cfg.baseline_end.isoformat() if cfg and cfg.baseline_end else None,
|
||||||
|
"recipients": (cfg.recipients if cfg and cfg.recipients else ""),
|
||||||
|
"last_run_date": cfg.last_run_date.isoformat() if cfg and cfg.last_run_date else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_report_config(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Return the project's nightly-report config (or defaults if not set yet)."""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
return _config_dict(cfg, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def put_report_config(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Create or update the project's nightly-report config (JSON body)."""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
created = cfg is None
|
||||||
|
if cfg is None:
|
||||||
|
cfg = SoundReportConfig(id=str(uuid.uuid4()), project_id=project_id)
|
||||||
|
db.add(cfg)
|
||||||
|
|
||||||
|
if "enabled" in data:
|
||||||
|
cfg.enabled = bool(data["enabled"])
|
||||||
|
if "report_time" in data:
|
||||||
|
cfg.report_time = _validate_hhmm(data["report_time"])
|
||||||
|
if "metric_keys" in data:
|
||||||
|
mk = data["metric_keys"]
|
||||||
|
mk = mk if isinstance(mk, str) else ",".join(mk or [])
|
||||||
|
cfg.metric_keys = ",".join(_parse_metrics(mk))
|
||||||
|
if "baseline_mode" in data:
|
||||||
|
bm = str(data["baseline_mode"]).lower()
|
||||||
|
if bm not in ("captured", "reference"):
|
||||||
|
raise HTTPException(status_code=400, detail="baseline_mode must be 'captured' or 'reference'")
|
||||||
|
cfg.baseline_mode = bm
|
||||||
|
if "baseline_start" in data or "baseline_end" in data:
|
||||||
|
bs = _parse_date(data.get("baseline_start") or None, "baseline_start")
|
||||||
|
be = _parse_date(data.get("baseline_end") or None, "baseline_end")
|
||||||
|
if (bs and not be) or (be and not bs):
|
||||||
|
raise HTTPException(status_code=400, detail="Provide both baseline dates, or neither.")
|
||||||
|
if bs and be and bs > be:
|
||||||
|
raise HTTPException(status_code=400, detail="baseline_start must be on or before baseline_end.")
|
||||||
|
cfg.baseline_start, cfg.baseline_end = bs, be
|
||||||
|
if "recipients" in data:
|
||||||
|
recips = data["recipients"]
|
||||||
|
if isinstance(recips, list):
|
||||||
|
recips = ",".join(recips)
|
||||||
|
cfg.recipients = (recips or "").strip() or None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cfg)
|
||||||
|
return {**_config_dict(cfg, project_id), "created": created}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics):
|
||||||
|
"""Validate inputs and resolve the baseline source.
|
||||||
|
|
||||||
|
Explicit baseline dates in the query override (captured mode with those
|
||||||
|
dates). Otherwise the project's saved config supplies the baseline (its
|
||||||
|
mode + dates) and the default metric set — so the manual view/run match
|
||||||
|
what the scheduled report does.
|
||||||
|
Returns (night_date, baseline_mode, baseline_start, baseline_end, metric_keys).
|
||||||
|
"""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
nd = _parse_date(night_date, "night_date") or _default_night_date()
|
||||||
|
bs = _parse_date(baseline_start, "baseline_start")
|
||||||
|
be = _parse_date(baseline_end, "baseline_end")
|
||||||
|
if (bs and not be) or (be and not bs):
|
||||||
|
raise HTTPException(status_code=400, detail="Provide both baseline_start and baseline_end, or neither.")
|
||||||
|
if bs and be and bs > be:
|
||||||
|
raise HTTPException(status_code=400, detail="baseline_start must be on or before baseline_end.")
|
||||||
|
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
if bs and be:
|
||||||
|
baseline_mode = "captured" # explicit dates win
|
||||||
|
elif cfg:
|
||||||
|
baseline_mode = cfg.baseline_mode # fall back to saved config
|
||||||
|
bs, be = cfg.baseline_start, cfg.baseline_end
|
||||||
|
else:
|
||||||
|
baseline_mode = "captured"
|
||||||
|
|
||||||
|
if metrics:
|
||||||
|
metric_keys = _parse_metrics(metrics)
|
||||||
|
elif cfg and cfg.metric_keys:
|
||||||
|
metric_keys = _parse_metrics(cfg.metric_keys)
|
||||||
|
else:
|
||||||
|
metric_keys = list(DEFAULT_METRICS)
|
||||||
|
|
||||||
|
return nd, baseline_mode, bs, be, metric_keys
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/nightly/view", response_class=HTMLResponse)
|
||||||
|
async def view_nightly_report(
|
||||||
|
project_id: str,
|
||||||
|
night_date: Optional[str] = Query(None, description="Evening date of the night (YYYY-MM-DD). Default: last night."),
|
||||||
|
baseline_start: Optional[str] = Query(None, description="Baseline range start (YYYY-MM-DD)."),
|
||||||
|
baseline_end: Optional[str] = Query(None, description="Baseline range end (YYYY-MM-DD)."),
|
||||||
|
metrics: Optional[str] = Query(None, description="Comma list, e.g. lmax,l01,l10,l90. Default: house set."),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Render the night report and return the HTML inline (preview — no write, no email)."""
|
||||||
|
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
|
||||||
|
try:
|
||||||
|
result = run_nightly_report(
|
||||||
|
db, project_id, nd,
|
||||||
|
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
|
||||||
|
send=False, # preview: no email
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error("nightly/view failed for %s (%s): %s", project_id, nd, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Report generation failed: {e}")
|
||||||
|
return HTMLResponse(result["html"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nightly/run")
|
||||||
|
async def run_nightly_report_endpoint(
|
||||||
|
project_id: str,
|
||||||
|
night_date: Optional[str] = Query(None, description="Evening date of the night (YYYY-MM-DD). Default: last night."),
|
||||||
|
baseline_start: Optional[str] = Query(None, description="Baseline range start (YYYY-MM-DD)."),
|
||||||
|
baseline_end: Optional[str] = Query(None, description="Baseline range end (YYYY-MM-DD)."),
|
||||||
|
metrics: Optional[str] = Query(None, description="Comma list, e.g. lmax,l01,l10,l90. Default: house set."),
|
||||||
|
send: bool = Query(True, description="Attempt email (dry-run until SMTP is configured)."),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Run the night report: build → write report.html/report.json to disk → email (best-effort).
|
||||||
|
|
||||||
|
This is the same path the scheduled morning tick will call. The `html` field
|
||||||
|
is omitted from the JSON response (it's large and on disk); use /view to see it.
|
||||||
|
"""
|
||||||
|
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
|
||||||
|
try:
|
||||||
|
result = run_nightly_report(
|
||||||
|
db, project_id, nd,
|
||||||
|
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
|
||||||
|
send=send,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error("nightly/run failed for %s (%s): %s", project_id, nd, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Report generation failed: {e}")
|
||||||
|
result.pop("html", None) # keep the JSON response lean — view it via /view or the file
|
||||||
|
result["view_url"] = (
|
||||||
|
f"/api/projects/{project_id}/reports/nightly/view"
|
||||||
|
f"?night_date={nd:%Y-%m-%d}"
|
||||||
|
+ (f"&baseline_start={bs:%Y-%m-%d}&baseline_end={be:%Y-%m-%d}" if bs and be else "")
|
||||||
|
+ (f"&metrics={','.join(metric_keys)}")
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Test email + generated-report archive
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-email")
|
||||||
|
async def send_test_email(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Send a small test email to verify the SMTP relay (dry-run if unconfigured).
|
||||||
|
|
||||||
|
Recipients: JSON body {"recipients": "..."} overrides; else the project's
|
||||||
|
configured recipients; else the REPORT_SMTP_RECIPIENTS env default.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
raw = (data or {}).get("recipients")
|
||||||
|
if not raw:
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
raw = cfg.recipients if cfg else None
|
||||||
|
recipients = None
|
||||||
|
if raw:
|
||||||
|
if isinstance(raw, list):
|
||||||
|
raw = ",".join(raw)
|
||||||
|
recipients = [r.strip() for r in raw.split(",") if r.strip()]
|
||||||
|
|
||||||
|
from backend.services.report_email import send_report_email
|
||||||
|
body = (
|
||||||
|
"<div style=\"font:14px Arial,sans-serif\">"
|
||||||
|
f"Terra-View test email for <b>{escape(project.name)}</b>.<br>"
|
||||||
|
"If you got this, the nightly sound-report email path is working.</div>"
|
||||||
|
)
|
||||||
|
return send_report_email("Terra-View — nightly report test email", body, recipients=recipients)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_reports(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""List the generated report artifacts on disk for this project (newest first)."""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
base = Path("data/reports") / project_id
|
||||||
|
out = []
|
||||||
|
if base.exists():
|
||||||
|
for d in sorted((p for p in base.iterdir() if p.is_dir()), key=lambda p: p.name, reverse=True):
|
||||||
|
html_file = d / "report.html"
|
||||||
|
if html_file.exists():
|
||||||
|
st = html_file.stat()
|
||||||
|
out.append({
|
||||||
|
"night_date": d.name,
|
||||||
|
"view_url": f"/api/projects/{project_id}/reports/archive/{d.name}",
|
||||||
|
"xlsx_url": (f"/api/projects/{project_id}/reports/archive/{d.name}/xlsx"
|
||||||
|
if (d / "report.xlsx").exists() else None),
|
||||||
|
"size_bytes": st.st_size,
|
||||||
|
"generated_at": datetime.utcfromtimestamp(st.st_mtime).isoformat(),
|
||||||
|
})
|
||||||
|
return {"reports": out, "count": len(out)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/archive/{night_date}", response_class=HTMLResponse)
|
||||||
|
async def view_archived_report(project_id: str, night_date: str, db: Session = Depends(get_db)):
|
||||||
|
"""Serve a previously generated report.html from disk (the actual artifact)."""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
if not _DATE_RE.match(night_date):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)")
|
||||||
|
safe = _parse_date(night_date, "night_date") # also guards path traversal
|
||||||
|
path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.html"
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="No saved report for that date")
|
||||||
|
return HTMLResponse(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/archive/{night_date}/xlsx")
|
||||||
|
async def download_archived_xlsx(project_id: str, night_date: str, db: Session = Depends(get_db)):
|
||||||
|
"""Download a previously generated report.xlsx from disk."""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
if not _DATE_RE.match(night_date):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)")
|
||||||
|
safe = _parse_date(night_date, "night_date")
|
||||||
|
path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.xlsx"
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="No saved spreadsheet for that date")
|
||||||
|
return Response(
|
||||||
|
content=path.read_bytes(),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="night_report_{safe:%Y-%m-%d}.xlsx"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Reference baseline (fixed values typed per location — limits / prior averages)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/baseline")
|
||||||
|
async def get_baseline(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Return the baseline mode + per-location reference values + the metric/window
|
||||||
|
grid to render the editor."""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
mode = cfg.baseline_mode if cfg else "captured"
|
||||||
|
metric_keys = _parse_metrics(cfg.metric_keys) if cfg and cfg.metric_keys else list(DEFAULT_METRICS)
|
||||||
|
|
||||||
|
locations = db.query(MonitoringLocation).filter_by(
|
||||||
|
project_id=project_id, location_type="sound",
|
||||||
|
).order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
locations = [l for l in locations if getattr(l, "removed_at", None) is None]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"windows": [{"key": w.key, "label": w.label} for w in DEFAULT_WINDOWS],
|
||||||
|
"metrics": [{"key": k, "label": METRIC_REGISTRY[k].label} for k in metric_keys],
|
||||||
|
"locations": [
|
||||||
|
{"id": loc.id, "name": loc.name, "values": _location_reference_baseline(loc)}
|
||||||
|
for loc in locations
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/baseline")
|
||||||
|
async def put_baseline(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Save the baseline mode (on config) and per-location reference values
|
||||||
|
(on each location's metadata). Body:
|
||||||
|
{"mode": "reference",
|
||||||
|
"locations": {"<loc_id>": {"nighttime": {"l10": 85}, "evening": {...}}}}
|
||||||
|
"""
|
||||||
|
if not db.query(Project).filter_by(id=project_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
if "mode" in data:
|
||||||
|
bm = str(data["mode"]).lower()
|
||||||
|
if bm not in ("captured", "reference"):
|
||||||
|
raise HTTPException(status_code=400, detail="mode must be 'captured' or 'reference'")
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
||||||
|
if cfg is None:
|
||||||
|
cfg = SoundReportConfig(id=str(uuid.uuid4()), project_id=project_id)
|
||||||
|
db.add(cfg)
|
||||||
|
cfg.baseline_mode = bm
|
||||||
|
|
||||||
|
loc_values = data.get("locations") or {}
|
||||||
|
updated = 0
|
||||||
|
for loc_id, windows in loc_values.items():
|
||||||
|
loc = db.query(MonitoringLocation).filter_by(id=loc_id, project_id=project_id).first()
|
||||||
|
if not loc or not isinstance(windows, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta = json.loads(loc.location_metadata or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
meta = {}
|
||||||
|
clean: dict = {}
|
||||||
|
for wkey, mvals in windows.items():
|
||||||
|
if not isinstance(mvals, dict):
|
||||||
|
continue
|
||||||
|
cm = {}
|
||||||
|
for mkey, val in mvals.items():
|
||||||
|
if val in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cm[mkey] = round(float(val), 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if cm:
|
||||||
|
clean[wkey] = cm
|
||||||
|
if clean:
|
||||||
|
meta["report_baseline"] = clean
|
||||||
|
else:
|
||||||
|
meta.pop("report_baseline", None)
|
||||||
|
loc.location_metadata = json.dumps(meta)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "locations_updated": updated}
|
||||||
@@ -12,6 +12,7 @@ from backend.database import get_db
|
|||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||||
import uuid
|
import uuid
|
||||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -182,9 +183,6 @@ async def add_roster_unit(
|
|||||||
out_for_calibration: str = Form(None),
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
|
||||||
address: str = Form(None),
|
|
||||||
coordinates: str = Form(None),
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -249,9 +247,6 @@ async def add_roster_unit(
|
|||||||
out_for_calibration=out_for_calibration_bool,
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
|
||||||
address=address,
|
|
||||||
coordinates=coordinates,
|
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated=last_cal_date,
|
last_calibrated=last_cal_date,
|
||||||
@@ -273,19 +268,15 @@ async def add_roster_unit(
|
|||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty
|
# Auto-fill data from modem if pairing and fields are empty.
|
||||||
|
# Location/address/coordinates now come from MonitoringLocation via the
|
||||||
|
# active UnitAssignment, so there's nothing to copy from the modem row.
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
if modem:
|
||||||
if not unit.location and modem.location:
|
|
||||||
unit.location = modem.location
|
|
||||||
if not unit.address and modem.address:
|
|
||||||
unit.address = modem.address
|
|
||||||
if not unit.coordinates and modem.coordinates:
|
|
||||||
unit.coordinates = modem.coordinates
|
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -493,6 +484,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type or "seismograph",
|
"device_type": unit.device_type or "seismograph",
|
||||||
@@ -504,9 +497,11 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"active_location": active_loc,
|
||||||
"address": unit.address or "",
|
# Convenience fields so the unit-detail page can read the same shape
|
||||||
"coordinates": unit.coordinates or "",
|
# whether or not there's an active assignment.
|
||||||
|
"address": (active_loc or {}).get("address") or "",
|
||||||
|
"coordinates": (active_loc or {}).get("coordinates") or "",
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
||||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
@@ -538,9 +533,6 @@ async def edit_roster_unit(
|
|||||||
allocated_to_project_id: str = Form(None),
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
|
||||||
address: str = Form(None),
|
|
||||||
coordinates: str = Form(None),
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -565,8 +557,6 @@ async def edit_roster_unit(
|
|||||||
cascade_deployed: str = Form(None),
|
cascade_deployed: str = Form(None),
|
||||||
cascade_retired: str = Form(None),
|
cascade_retired: str = Form(None),
|
||||||
cascade_project: str = Form(None),
|
cascade_project: str = Form(None),
|
||||||
cascade_location: str = Form(None),
|
|
||||||
cascade_coordinates: str = Form(None),
|
|
||||||
cascade_note: str = Form(None),
|
cascade_note: str = Form(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -620,9 +610,6 @@ async def edit_roster_unit(
|
|||||||
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
|
||||||
unit.address = address
|
|
||||||
unit.coordinates = coordinates
|
|
||||||
unit.last_updated = datetime.utcnow()
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -630,20 +617,15 @@ async def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_cal_date
|
unit.next_calibration_due = next_cal_date
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty
|
# Auto-fill data from modem if pairing and fields are empty.
|
||||||
|
# Location/address/coordinates live on MonitoringLocation now, nothing
|
||||||
|
# to copy across roster rows.
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
if modem:
|
||||||
# Only fill if the device field is empty
|
|
||||||
if not unit.location and modem.location:
|
|
||||||
unit.location = modem.location
|
|
||||||
if not unit.address and modem.address:
|
|
||||||
unit.address = modem.address
|
|
||||||
if not unit.coordinates and modem.coordinates:
|
|
||||||
unit.coordinates = modem.coordinates
|
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -769,26 +751,6 @@ async def edit_roster_unit(
|
|||||||
record_history(db, paired_unit.id, "project_change", "project_id",
|
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||||
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
# Cascade address/location
|
|
||||||
if cascade_location in ['true', 'True', '1', 'yes']:
|
|
||||||
old_paired_address = paired_unit.address
|
|
||||||
old_paired_location = paired_unit.location
|
|
||||||
paired_unit.address = address
|
|
||||||
paired_unit.location = location
|
|
||||||
paired_unit.last_updated = datetime.utcnow()
|
|
||||||
if old_paired_address != address:
|
|
||||||
record_history(db, paired_unit.id, "address_change", "address",
|
|
||||||
old_paired_address or "", address or "", f"cascade from {unit_id}")
|
|
||||||
|
|
||||||
# Cascade coordinates
|
|
||||||
if cascade_coordinates in ['true', 'True', '1', 'yes']:
|
|
||||||
old_paired_coords = paired_unit.coordinates
|
|
||||||
paired_unit.coordinates = coordinates
|
|
||||||
paired_unit.last_updated = datetime.utcnow()
|
|
||||||
if old_paired_coords != coordinates:
|
|
||||||
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
|
|
||||||
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
|
|
||||||
|
|
||||||
# Cascade note
|
# Cascade note
|
||||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||||
old_paired_note = paired_unit.note
|
old_paired_note = paired_unit.note
|
||||||
@@ -1011,9 +973,8 @@ async def import_csv(
|
|||||||
- retired: Boolean
|
- retired: Boolean
|
||||||
- note: Notes about the unit
|
- note: Notes about the unit
|
||||||
- project_id: Project identifier
|
- project_id: Project identifier
|
||||||
- location: Location description
|
(Location / address / coordinates are not roster fields anymore — they
|
||||||
- address: Street address
|
live on the MonitoringLocation a unit is assigned to.)
|
||||||
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
|
||||||
|
|
||||||
Seismograph-specific:
|
Seismograph-specific:
|
||||||
- last_calibrated: Date (YYYY-MM-DD)
|
- last_calibrated: Date (YYYY-MM-DD)
|
||||||
@@ -1126,9 +1087,6 @@ async def import_csv(
|
|||||||
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
||||||
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
||||||
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
||||||
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
|
|
||||||
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
|
|
||||||
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
|
|
||||||
existing_unit.last_updated = datetime.utcnow()
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -1194,9 +1152,6 @@ async def import_csv(
|
|||||||
retired=_parse_bool(row.get('retired', '')),
|
retired=_parse_bool(row.get('retired', '')),
|
||||||
note=_get_csv_value(row, 'note', ''),
|
note=_get_csv_value(row, 'note', ''),
|
||||||
project_id=_get_csv_value(row, 'project_id'),
|
project_id=_get_csv_value(row, 'project_id'),
|
||||||
location=_get_csv_value(row, 'location'),
|
|
||||||
address=_get_csv_value(row, 'address'),
|
|
||||||
coordinates=_get_csv_value(row, 'coordinates'),
|
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
||||||
last_calibrated=last_cal,
|
last_calibrated=last_cal,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
from backend.services.unit_location import bulk_active_locations
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -21,11 +22,14 @@ def export_roster_csv(db: Session = Depends(get_db)):
|
|||||||
"""Export all roster units to CSV"""
|
"""Export all roster units to CSV"""
|
||||||
units = db.query(RosterUnit).all()
|
units = db.query(RosterUnit).all()
|
||||||
|
|
||||||
# Create CSV in memory
|
# Create CSV in memory. Location lives on MonitoringLocation now, so
|
||||||
|
# we don't export legacy address/coordinates/location columns here —
|
||||||
|
# round-trip CSV editing would otherwise look like it edits unit
|
||||||
|
# location, when it can't.
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
||||||
'note', 'project_id', 'location', 'address', 'coordinates',
|
'note', 'project_id',
|
||||||
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||||
'ip_address', 'phone_number', 'hardware_model'
|
'ip_address', 'phone_number', 'hardware_model'
|
||||||
]
|
]
|
||||||
@@ -42,9 +46,6 @@ def export_roster_csv(db: Session = Depends(get_db)):
|
|||||||
'retired': 'true' if unit.retired else 'false',
|
'retired': 'true' if unit.retired else 'false',
|
||||||
'note': unit.note or '',
|
'note': unit.note or '',
|
||||||
'project_id': unit.project_id or '',
|
'project_id': unit.project_id or '',
|
||||||
'location': unit.location or '',
|
|
||||||
'address': unit.address or '',
|
|
||||||
'coordinates': unit.coordinates or '',
|
|
||||||
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
||||||
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
|
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
|
||||||
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
||||||
@@ -82,6 +83,7 @@ def get_table_stats(db: Session = Depends(get_db)):
|
|||||||
def get_all_roster_units(db: Session = Depends(get_db)):
|
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||||
"""Get all roster units for management table"""
|
"""Get all roster units for management table"""
|
||||||
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||||
|
active_locs = bulk_active_locations(db, units)
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
@@ -90,10 +92,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"address": (active_locs.get(unit.id) or {}).get("address") or "",
|
||||||
"address": unit.address or "",
|
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
|
||||||
"coordinates": unit.coordinates or "",
|
"location_name": (active_locs.get(unit.id) or {}).get("name") or "",
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
@@ -267,6 +269,7 @@ 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")
|
||||||
@@ -293,6 +296,7 @@ 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 "psi",
|
||||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +338,7 @@ 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 "psi",
|
||||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,29 +91,43 @@ async def get_slm_units(
|
|||||||
|
|
||||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||||
for unit in units:
|
for unit in units:
|
||||||
|
# Legacy default from the roster field; refined from SLMM's cached status below.
|
||||||
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
|
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
|
||||||
|
unit.measurement_state = None
|
||||||
|
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
|
||||||
|
|
||||||
if include_measurement:
|
if include_measurement:
|
||||||
async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
|
# SLMM's /roster carries each unit's CACHED status (last_seen,
|
||||||
|
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device
|
||||||
|
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects
|
||||||
|
# real monitoring without sending Measure? to the device (which the old
|
||||||
|
# /measurement-state did) and competing with DOD polling. One call covers all.
|
||||||
|
slmm_status = {}
|
||||||
try:
|
try:
|
||||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json().get("measurement_state")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
|
|
||||||
if deployed_units:
|
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
|
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster")
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
if r.status_code == 200:
|
||||||
|
for dev in (r.json().get("devices") or []):
|
||||||
|
slmm_status[dev.get("unit_id")] = dev.get("status") or {}
|
||||||
|
except Exception:
|
||||||
|
slmm_status = {}
|
||||||
|
|
||||||
for unit, state in zip(deployed_units, results):
|
# "Recent" = the monitor has a fresh successful read. last_seen only advances
|
||||||
if isinstance(state, Exception):
|
# on a successful poll, so staleness == the device isn't being reached.
|
||||||
unit.measurement_state = None
|
recent_cutoff = datetime.utcnow() - timedelta(minutes=5)
|
||||||
else:
|
for unit in units:
|
||||||
unit.measurement_state = state
|
st = slmm_status.get(unit.id)
|
||||||
|
if not st:
|
||||||
|
continue
|
||||||
|
unit.measurement_state = st.get("measurement_state")
|
||||||
|
last_seen = st.get("last_seen")
|
||||||
|
if last_seen:
|
||||||
|
try:
|
||||||
|
ls = datetime.fromisoformat(last_seen.replace("Z", ""))
|
||||||
|
unit.is_recent = ls > recent_cutoff
|
||||||
|
unit.cache_last_seen = ls # the real freshness the monitor updates
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/slm_device_list.html", {
|
return templates.TemplateResponse("partials/slm_device_list.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -157,25 +171,18 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
is_measuring = False
|
is_measuring = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor
|
||||||
# Get measurement state
|
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we
|
||||||
state_response = await client.get(
|
# no longer fire Measure? + a fresh DOD read at the device on every command-
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
# center load (which competed with DOD polling for the single connection).
|
||||||
)
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
if state_response.status_code == 200:
|
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||||
state_data = state_response.json()
|
if r.status_code == 200:
|
||||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
current_status = r.json().get("data", {})
|
||||||
is_measuring = state_data.get("is_measuring", False)
|
measurement_state = current_status.get("measurement_state")
|
||||||
|
is_measuring = measurement_state in ("Start", "Measure")
|
||||||
# Get live status (measurement_start_time is already stored in SLMM database)
|
|
||||||
status_response = await client.get(
|
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
|
||||||
)
|
|
||||||
if status_response.status_code == 200:
|
|
||||||
status_data = status_response.json()
|
|
||||||
current_status = status_data.get("data", {})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get status for {unit_id}: {e}")
|
logger.error(f"Failed to get cached status for {unit_id}: {e}")
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/slm_live_view.html", {
|
return templates.TemplateResponse("partials/slm_live_view.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import os
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,13 +59,14 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||||
|
|
||||||
|
loc = get_active_location(db, unit_id)
|
||||||
return {
|
return {
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"device_type": "slm",
|
"device_type": "slm",
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"model": unit.slm_model or "NL-43",
|
"model": unit.slm_model or "NL-43",
|
||||||
"location": unit.address or unit.location,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"coordinates": unit.coordinates,
|
"coordinates": (loc or {}).get("coordinates") or "",
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"status": status_data,
|
"status": status_data,
|
||||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
|||||||
@@ -231,6 +231,76 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
|
|||||||
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{unit_id}/monitor")
|
||||||
|
async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str):
|
||||||
|
"""
|
||||||
|
Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed).
|
||||||
|
|
||||||
|
This is the shared ~1Hz DOD feed: many clients subscribe to one device feed
|
||||||
|
(no single-connection contention) and it carries L1/L10 (which the DRD
|
||||||
|
/stream cannot). Preferred over /stream for the live view.
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)")
|
||||||
|
|
||||||
|
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
|
||||||
|
backend_ws = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend_ws = await websockets.connect(target_ws_url)
|
||||||
|
logger.info(f"Connected to SLMM monitor feed for {unit_id}")
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
"""Backend monitor frames -> browser."""
|
||||||
|
async for message in backend_ws:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
|
||||||
|
async def watch_client():
|
||||||
|
"""Drain client frames; raises WebSocketDisconnect on close so we can
|
||||||
|
tear the pair down (the monitor feed is server->client only)."""
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
|
||||||
|
# When EITHER side ends (browser disconnects or backend closes), cancel the
|
||||||
|
# other immediately — avoids sending into a closed socket (the
|
||||||
|
# "Unexpected ASGI message after close" race that asyncio.gather leaves open).
|
||||||
|
tasks = [asyncio.ensure_future(forward_to_client()),
|
||||||
|
asyncio.ensure_future(watch_client())]
|
||||||
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
# Await ALL tasks (the done one AND the cancelled one) and swallow both
|
||||||
|
# the expected WebSocketDisconnect and CancelledError. CancelledError is a
|
||||||
|
# BaseException, so a bare `except Exception` misses it — that's what leaked
|
||||||
|
# the traceback on stop; and awaiting only `pending` left the done task's
|
||||||
|
# exception unretrieved.
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
except websockets.exceptions.WebSocketException as e:
|
||||||
|
logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}")
|
||||||
|
finally:
|
||||||
|
if backend_ws:
|
||||||
|
try:
|
||||||
|
await backend_ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(f"WebSocket monitor proxy closed for {unit_id}")
|
||||||
|
|
||||||
|
|
||||||
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
||||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy_to_slmm(path: str, request: Request):
|
async def proxy_to_slmm(path: str, request: Request):
|
||||||
|
|||||||
+12
-16
@@ -5,6 +5,7 @@ from typing import Dict, Any, Optional
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
@@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"])
|
|||||||
@router.get("/unit/{unit_id}")
|
@router.get("/unit/{unit_id}")
|
||||||
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Returns detailed data for a single unit.
|
Returns detailed data for a single unit, including its active deployment
|
||||||
|
location (or None if benched / unassigned).
|
||||||
"""
|
"""
|
||||||
snapshot = emit_status_snapshot()
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
@@ -21,17 +23,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
unit_data = snapshot["units"][unit_id]
|
unit_data = snapshot["units"][unit_id]
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
# Mock coordinates for now (will be replaced with real data)
|
|
||||||
mock_coords = {
|
|
||||||
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
|
||||||
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
|
||||||
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
|
||||||
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
|
||||||
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
|
||||||
}
|
|
||||||
|
|
||||||
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
@@ -41,7 +33,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"last_file": unit_data.get("fname", ""),
|
"last_file": unit_data.get("fname", ""),
|
||||||
"deployed": unit_data["deployed"],
|
"deployed": unit_data["deployed"],
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"coordinates": coords
|
"active_location": active_loc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,12 +41,16 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Get unit data directly from the roster (for settings/configuration).
|
Get unit data directly from the roster (for settings/configuration).
|
||||||
|
Address/coordinates come from the active MonitoringLocation, not the
|
||||||
|
roster row.
|
||||||
"""
|
"""
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
@@ -62,9 +58,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"location": unit.location,
|
"active_location": active_loc,
|
||||||
"address": unit.address,
|
"address": (active_loc or {}).get("address") or "",
|
||||||
"coordinates": unit.coordinates,
|
"coordinates": (active_loc or {}).get("coordinates") or "",
|
||||||
"slm_host": unit.slm_host,
|
"slm_host": unit.slm_host,
|
||||||
"slm_tcp_port": unit.slm_tcp_port,
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
"slm_ftp_port": unit.slm_ftp_port,
|
"slm_ftp_port": unit.slm_ftp_port,
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""
|
||||||
|
Calibration Sync Service
|
||||||
|
|
||||||
|
Pulls device-reported calibration dates from SFM event sidecars and updates
|
||||||
|
RosterUnit.last_calibrated when the device has a newer record than what
|
||||||
|
Terra-View has stored.
|
||||||
|
|
||||||
|
Conflict rule: events-as-truth, but don't go backwards.
|
||||||
|
- If the newest event's calibration_date == unit.last_calibrated → no-op.
|
||||||
|
- If the last UnitHistory change for last_calibrated is newer than the
|
||||||
|
newest event's timestamp → skip (a manual edit was made after this
|
||||||
|
event landed; manual wins until a fresher event arrives).
|
||||||
|
- Otherwise → write the event's calibration_date, recompute
|
||||||
|
next_calibration_due, and log a UnitHistory row with source='sfm_event'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import schedule
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import SessionLocal
|
||||||
|
from backend.models import RosterUnit, UnitHistory, UserPreferences
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cal_interval(db: Session) -> int:
|
||||||
|
prefs = db.query(UserPreferences).first()
|
||||||
|
if prefs and prefs.calibration_interval_days:
|
||||||
|
return prefs.calibration_interval_days
|
||||||
|
return 365
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_ts(value: Any) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.replace(tzinfo=None) if value.tzinfo else value
|
||||||
|
try:
|
||||||
|
s = str(value).replace("Z", "")
|
||||||
|
if "+" in s:
|
||||||
|
s = s.split("+", 1)[0]
|
||||||
|
return datetime.fromisoformat(s)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(f"Could not parse event timestamp: {value!r}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cal_date(value: Any) -> Optional[date]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, date) and not isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value)).date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(f"Could not parse calibration_date: {value!r}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_latest_event(client: httpx.AsyncClient, serial: str) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{SFM_BASE_URL}/db/events",
|
||||||
|
params={"serial": serial, "limit": 1},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
events = data.get("events", [])
|
||||||
|
return events[0] if events else None
|
||||||
|
except (httpx.HTTPError, ValueError) as e:
|
||||||
|
logger.warning(f"Failed to fetch latest event for {serial}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_event_sidecar(client: httpx.AsyncClient, event_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"{SFM_BASE_URL}/db/events/{event_id}/sidecar")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as e:
|
||||||
|
logger.warning(f"Failed to fetch sidecar for event {event_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_unit_calibration(
|
||||||
|
db: Session,
|
||||||
|
unit: RosterUnit,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Sync calibration for one seismograph unit. Returns a result dict."""
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"unit_id": unit.id,
|
||||||
|
"action": "checked",
|
||||||
|
"old": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||||
|
"new": None,
|
||||||
|
"event_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
event = await _get_latest_event(client, unit.id)
|
||||||
|
if not event:
|
||||||
|
result["action"] = "no_event"
|
||||||
|
return result
|
||||||
|
|
||||||
|
sidecar = await _get_event_sidecar(client, event["id"])
|
||||||
|
if not sidecar:
|
||||||
|
result["action"] = "no_sidecar"
|
||||||
|
return result
|
||||||
|
|
||||||
|
device = sidecar.get("device") or {}
|
||||||
|
event_cal = _parse_cal_date(device.get("calibration_date"))
|
||||||
|
if not event_cal:
|
||||||
|
result["action"] = "no_cal_in_sidecar"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["event_id"] = event["id"]
|
||||||
|
result["new"] = event_cal.isoformat()
|
||||||
|
|
||||||
|
if unit.last_calibrated == event_cal:
|
||||||
|
result["action"] = "already_in_sync"
|
||||||
|
return result
|
||||||
|
|
||||||
|
event_ts = _parse_event_ts(event.get("timestamp"))
|
||||||
|
last_change = (
|
||||||
|
db.query(UnitHistory)
|
||||||
|
.filter(
|
||||||
|
UnitHistory.unit_id == unit.id,
|
||||||
|
UnitHistory.field_name == "last_calibrated",
|
||||||
|
)
|
||||||
|
.order_by(UnitHistory.changed_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_change and event_ts and last_change.changed_at > event_ts:
|
||||||
|
result["action"] = "skipped_manual_newer"
|
||||||
|
return result
|
||||||
|
|
||||||
|
old_cal = unit.last_calibrated
|
||||||
|
unit.last_calibrated = event_cal
|
||||||
|
unit.next_calibration_due = event_cal + timedelta(days=_get_cal_interval(db))
|
||||||
|
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit.id,
|
||||||
|
change_type="calibration_status_change",
|
||||||
|
field_name="last_calibrated",
|
||||||
|
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
|
||||||
|
new_value=event_cal.strftime("%Y-%m-%d"),
|
||||||
|
source="sfm_event",
|
||||||
|
notes=f"Synced from event {event['id']}",
|
||||||
|
))
|
||||||
|
|
||||||
|
result["action"] = "updated"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_all_calibrations(db: Optional[Session] = None) -> Dict[str, Any]:
|
||||||
|
"""Sync calibration for every non-retired seismograph.
|
||||||
|
|
||||||
|
If `db` is provided the caller owns the session and commit. Otherwise
|
||||||
|
a session is opened, committed, and closed locally — this is what the
|
||||||
|
scheduled job uses.
|
||||||
|
"""
|
||||||
|
owns_session = db is None
|
||||||
|
if owns_session:
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
summary: Dict[str, Any] = {
|
||||||
|
"started_at": datetime.utcnow().isoformat(),
|
||||||
|
"checked": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"skipped_manual_newer": 0,
|
||||||
|
"already_in_sync": 0,
|
||||||
|
"no_event": 0,
|
||||||
|
"no_sidecar": 0,
|
||||||
|
"no_cal_in_sidecar": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
units = (
|
||||||
|
db.query(RosterUnit)
|
||||||
|
.filter(
|
||||||
|
RosterUnit.retired == False,
|
||||||
|
RosterUnit.device_type == "seismograph",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
for unit in units:
|
||||||
|
summary["checked"] += 1
|
||||||
|
try:
|
||||||
|
r = await sync_unit_calibration(db, unit, client)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error syncing calibration for {unit.id}")
|
||||||
|
summary["errors"] += 1
|
||||||
|
summary["results"].append({"unit_id": unit.id, "action": "error", "error": str(e)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
summary["results"].append(r)
|
||||||
|
action = r["action"]
|
||||||
|
if action in summary:
|
||||||
|
summary[action] += 1
|
||||||
|
|
||||||
|
if owns_session:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if owns_session:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
summary["finished_at"] = datetime.utcnow().isoformat()
|
||||||
|
logger.info(
|
||||||
|
f"Calibration sync done: checked={summary['checked']} "
|
||||||
|
f"updated={summary['updated']} skipped_manual={summary['skipped_manual_newer']} "
|
||||||
|
f"in_sync={summary['already_in_sync']} errors={summary['errors']}"
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Background scheduler — runs once daily. Modeled on backup_scheduler.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CalibrationSyncScheduler:
|
||||||
|
"""Runs sync_all_calibrations() once per day at a fixed local time."""
|
||||||
|
|
||||||
|
def __init__(self, run_at: str = "03:15"):
|
||||||
|
self.run_at = run_at
|
||||||
|
self.is_running = False
|
||||||
|
self.thread: Optional[threading.Thread] = None
|
||||||
|
self.last_run: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
def _job_wrapper(self):
|
||||||
|
"""Run the async sync in a fresh event loop (we're on a worker thread)."""
|
||||||
|
try:
|
||||||
|
self.last_run = asyncio.run(sync_all_calibrations())
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Calibration sync job failed: {e}")
|
||||||
|
self.last_run = {"error": str(e), "finished_at": datetime.utcnow().isoformat()}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.is_running:
|
||||||
|
return
|
||||||
|
logger.info(f"Starting calibration sync scheduler (daily at {self.run_at})")
|
||||||
|
schedule.every().day.at(self.run_at).do(self._job_wrapper)
|
||||||
|
self.is_running = True
|
||||||
|
self.thread = threading.Thread(target=self._loop, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
while self.is_running:
|
||||||
|
schedule.run_pending()
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if not self.is_running:
|
||||||
|
return
|
||||||
|
logger.info("Stopping calibration sync scheduler")
|
||||||
|
self.is_running = False
|
||||||
|
if self.thread:
|
||||||
|
self.thread.join(timeout=5)
|
||||||
|
|
||||||
|
def status(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"running": self.is_running,
|
||||||
|
"run_at": self.run_at,
|
||||||
|
"last_run": self.last_run,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_scheduler: Optional[CalibrationSyncScheduler] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_calibration_sync_scheduler() -> CalibrationSyncScheduler:
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = CalibrationSyncScheduler()
|
||||||
|
return _scheduler
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""
|
||||||
|
Deployment-history calendar service — builds the data structure for the
|
||||||
|
fleet-wide deployment-history grid (`/tools/deployment-history`).
|
||||||
|
|
||||||
|
For each calendar day in a 12-month window, computes which projects had
|
||||||
|
at least one unit assigned to a location on that day. Renders as
|
||||||
|
multi-month grid (job-planner style) with project-colored bars per day.
|
||||||
|
|
||||||
|
Distinct from `services/fleet_calendar_service.py` which renders
|
||||||
|
forward-looking RESERVATIONS for the planner. This one is purely
|
||||||
|
historical / current — it walks `unit_assignments` instead of
|
||||||
|
`job_reservations`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from calendar import monthrange
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from backend.models import Project, UnitAssignment
|
||||||
|
|
||||||
|
|
||||||
|
# Color palette for projects without an explicit color attribute. Chosen
|
||||||
|
# to have decent contrast on both light and dark backgrounds; cycles
|
||||||
|
# deterministically by SHA1(project_id).
|
||||||
|
_PROJECT_COLOR_PALETTE = [
|
||||||
|
"#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d",
|
||||||
|
"#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8",
|
||||||
|
"#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _color_for_project(project_id: str) -> str:
|
||||||
|
"""Deterministic color assignment from a fixed palette."""
|
||||||
|
h = hashlib.sha1(project_id.encode("utf-8")).digest()[0]
|
||||||
|
return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)]
|
||||||
|
|
||||||
|
|
||||||
|
def _month_short(m: int) -> str:
|
||||||
|
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _month_full(m: int) -> str:
|
||||||
|
return ["January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"][m - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def get_deployment_history_data(
|
||||||
|
db: Session,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Build the calendar data structure for a 12-month window starting at
|
||||||
|
(start_year, start_month).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"months": [
|
||||||
|
{
|
||||||
|
"year": int,
|
||||||
|
"month": int, # 1-12
|
||||||
|
"name": "January",
|
||||||
|
"short_name": "Jan",
|
||||||
|
"year_short": "26",
|
||||||
|
"num_days": int,
|
||||||
|
"first_weekday": int, # 0=Mon..6=Sun (datetime.weekday())
|
||||||
|
"active_days": {
|
||||||
|
day_num: [project_id, project_id, ...] # projects with
|
||||||
|
# ≥1 active assignment
|
||||||
|
# on that day
|
||||||
|
},
|
||||||
|
},
|
||||||
|
... # 12 entries
|
||||||
|
],
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": str,
|
||||||
|
"name": str,
|
||||||
|
"color": str,
|
||||||
|
"status": str,
|
||||||
|
"client_name": str | None,
|
||||||
|
"assignment_count": int, # total assignments contributing to
|
||||||
|
# this 12-month window
|
||||||
|
"first_active": "YYYY-MM-DD" | None,
|
||||||
|
"last_active": "YYYY-MM-DD" | None,
|
||||||
|
},
|
||||||
|
... # only projects with
|
||||||
|
# ≥1 assignment in the
|
||||||
|
# window, sorted by
|
||||||
|
# first_active ASC
|
||||||
|
],
|
||||||
|
"total_assignments": int,
|
||||||
|
"total_active_units": int, # distinct unit_ids across the window
|
||||||
|
"window": {
|
||||||
|
"start_year": int,
|
||||||
|
"start_month": int,
|
||||||
|
"end_year": int,
|
||||||
|
"end_month": int,
|
||||||
|
"first_date": "YYYY-MM-DD",
|
||||||
|
"last_date": "YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Compute window edges.
|
||||||
|
first_date = date(start_year, start_month, 1)
|
||||||
|
# 12 months → end on day-1 of (start + 12)
|
||||||
|
end_year = start_year + ((start_month + 10) // 12)
|
||||||
|
end_month = ((start_month + 10) % 12) + 1
|
||||||
|
last_date = date(end_year, end_month, monthrange(end_year, end_month)[1])
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Fetch every assignment that overlaps the window. An assignment
|
||||||
|
# overlaps if assigned_at <= last_date AND (assigned_until is NULL
|
||||||
|
# OR assigned_until >= first_date).
|
||||||
|
assignments = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time()))
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711 — active
|
||||||
|
UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve referenced projects in one query.
|
||||||
|
proj_ids = {a.project_id for a in assignments}
|
||||||
|
proj_map = {
|
||||||
|
p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all()
|
||||||
|
} if proj_ids else {}
|
||||||
|
|
||||||
|
# Resolve location names in one batch query (used by the Gantt view
|
||||||
|
# for per-bar tooltips).
|
||||||
|
from backend.models import MonitoringLocation
|
||||||
|
loc_ids = {a.location_id for a in assignments}
|
||||||
|
loc_name_map = {
|
||||||
|
l.id: l.name for l in db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.id.in_(loc_ids)
|
||||||
|
).all()
|
||||||
|
} if loc_ids else {}
|
||||||
|
|
||||||
|
# Compute "active days per project" by walking each assignment and
|
||||||
|
# adding every day in its [start, end] ∩ [first_date, last_date].
|
||||||
|
# O(N_assignments × avg_window_days); for a typical fleet this is
|
||||||
|
# bounded (hundreds of assignments × hundreds of days = manageable).
|
||||||
|
# Also collect raw per-assignment bar data for the Gantt view.
|
||||||
|
project_active_days: dict[str, set[date]] = {}
|
||||||
|
project_first_active: dict[str, date] = {}
|
||||||
|
project_last_active: dict[str, date] = {}
|
||||||
|
project_assignment_count: dict[str, int] = {}
|
||||||
|
project_bars: dict[str, list[dict]] = {}
|
||||||
|
distinct_units: set[str] = set()
|
||||||
|
|
||||||
|
for a in assignments:
|
||||||
|
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
|
||||||
|
end_dt = a.assigned_until or now
|
||||||
|
end = min(end_dt.date(), last_date)
|
||||||
|
if end < start:
|
||||||
|
continue
|
||||||
|
days = project_active_days.setdefault(a.project_id, set())
|
||||||
|
d = start
|
||||||
|
while d <= end:
|
||||||
|
days.add(d)
|
||||||
|
d += timedelta(days=1)
|
||||||
|
project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1
|
||||||
|
distinct_units.add(a.unit_id)
|
||||||
|
# Track first/last active dates in the window.
|
||||||
|
prev_first = project_first_active.get(a.project_id)
|
||||||
|
if prev_first is None or start < prev_first:
|
||||||
|
project_first_active[a.project_id] = start
|
||||||
|
prev_last = project_last_active.get(a.project_id)
|
||||||
|
if prev_last is None or end > prev_last:
|
||||||
|
project_last_active[a.project_id] = end
|
||||||
|
|
||||||
|
# Per-assignment bar data — used by the Gantt view's renderer.
|
||||||
|
# `is_active` reflects whether the assignment_until was still NULL
|
||||||
|
# at fetch time (open-ended deployment); the clipped `end` here
|
||||||
|
# is just for visual bar drawing.
|
||||||
|
project_bars.setdefault(a.project_id, []).append({
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
|
||||||
|
"start": start.isoformat(),
|
||||||
|
"end": end.isoformat(),
|
||||||
|
"is_active": a.assigned_until is None,
|
||||||
|
"source": a.source,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build the projects array (sorted by first_active ascending so the
|
||||||
|
# legend reads in deployment-order).
|
||||||
|
projects_data = []
|
||||||
|
for pid, days in project_active_days.items():
|
||||||
|
p = proj_map.get(pid)
|
||||||
|
if not p:
|
||||||
|
# Assignment references a deleted project — surface it anyway
|
||||||
|
# with a placeholder name, since the bars still need a label.
|
||||||
|
projects_data.append({
|
||||||
|
"id": pid,
|
||||||
|
"name": "(deleted project)",
|
||||||
|
"color": _color_for_project(pid),
|
||||||
|
"status": "deleted",
|
||||||
|
"client_name": None,
|
||||||
|
"assignment_count": project_assignment_count.get(pid, 0),
|
||||||
|
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
|
||||||
|
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
|
||||||
|
"bars": project_bars.get(pid, []),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
projects_data.append({
|
||||||
|
"id": pid,
|
||||||
|
"name": p.name,
|
||||||
|
"color": _color_for_project(pid),
|
||||||
|
"status": p.status or "active",
|
||||||
|
"client_name": p.client_name,
|
||||||
|
"assignment_count": project_assignment_count.get(pid, 0),
|
||||||
|
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
|
||||||
|
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
|
||||||
|
"bars": project_bars.get(pid, []),
|
||||||
|
})
|
||||||
|
|
||||||
|
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
|
||||||
|
|
||||||
|
# ── Per-unit view data (Gantt-by-Unit tab) ────────────────────────
|
||||||
|
# Same source assignments, re-grouped by unit_id. Each bar carries
|
||||||
|
# the project's color + name so the renderer can paint by job
|
||||||
|
# without doing a second lookup.
|
||||||
|
unit_bars: dict[str, list[dict]] = {}
|
||||||
|
project_lookup = {p["id"]: p for p in projects_data}
|
||||||
|
for a in assignments:
|
||||||
|
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
|
||||||
|
end_dt = a.assigned_until or now
|
||||||
|
end = min(end_dt.date(), last_date)
|
||||||
|
if end < start:
|
||||||
|
continue
|
||||||
|
p_info = project_lookup.get(a.project_id, {})
|
||||||
|
unit_bars.setdefault(a.unit_id, []).append({
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"project_name": p_info.get("name", "(deleted project)"),
|
||||||
|
"project_color": p_info.get("color", _color_for_project(a.project_id)),
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
|
||||||
|
"start": start.isoformat(),
|
||||||
|
"end": end.isoformat(),
|
||||||
|
"is_active": a.assigned_until is None,
|
||||||
|
"source": a.source,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort units by first-active date so the most-recently-deployed
|
||||||
|
# units sit at the top. Reverse if we want oldest-first.
|
||||||
|
units_data = []
|
||||||
|
for uid, bars in unit_bars.items():
|
||||||
|
bars.sort(key=lambda b: b["start"])
|
||||||
|
first_start = bars[0]["start"]
|
||||||
|
# "active now" flag = any bar is still active
|
||||||
|
any_active = any(b["is_active"] for b in bars)
|
||||||
|
units_data.append({
|
||||||
|
"id": uid,
|
||||||
|
"bars": bars,
|
||||||
|
"first_active": first_start,
|
||||||
|
"assignment_count": len(bars),
|
||||||
|
"any_active": any_active,
|
||||||
|
})
|
||||||
|
units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"]))
|
||||||
|
|
||||||
|
# Now build the months array.
|
||||||
|
months_data = []
|
||||||
|
cur_year, cur_month = start_year, start_month
|
||||||
|
for _ in range(12):
|
||||||
|
num_days = monthrange(cur_year, cur_month)[1]
|
||||||
|
first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun
|
||||||
|
active_days: dict[int, list[str]] = {}
|
||||||
|
for day_num in range(1, num_days + 1):
|
||||||
|
d = date(cur_year, cur_month, day_num)
|
||||||
|
day_projects = [
|
||||||
|
pid for pid, days in project_active_days.items()
|
||||||
|
if d in days
|
||||||
|
]
|
||||||
|
if day_projects:
|
||||||
|
# Sort by the project's color-stable order so bars don't
|
||||||
|
# jitter between days.
|
||||||
|
day_projects.sort()
|
||||||
|
active_days[day_num] = day_projects
|
||||||
|
months_data.append({
|
||||||
|
"year": cur_year,
|
||||||
|
"month": cur_month,
|
||||||
|
"name": _month_full(cur_month),
|
||||||
|
"short_name": _month_short(cur_month),
|
||||||
|
"year_short": f"{cur_year % 100:02d}",
|
||||||
|
"num_days": num_days,
|
||||||
|
"first_weekday": first_weekday,
|
||||||
|
"active_days": active_days,
|
||||||
|
})
|
||||||
|
# Advance one month.
|
||||||
|
if cur_month == 12:
|
||||||
|
cur_year += 1
|
||||||
|
cur_month = 1
|
||||||
|
else:
|
||||||
|
cur_month += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"months": months_data,
|
||||||
|
"projects": projects_data,
|
||||||
|
"units": units_data,
|
||||||
|
"total_assignments": len(assignments),
|
||||||
|
"total_active_units": len(distinct_units),
|
||||||
|
"window": {
|
||||||
|
"start_year": start_year,
|
||||||
|
"start_month": start_month,
|
||||||
|
"end_year": end_year,
|
||||||
|
"end_month": end_month,
|
||||||
|
"first_date": first_date.isoformat(),
|
||||||
|
"last_date": last_date.isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_deployments_on_day(
|
||||||
|
db: Session,
|
||||||
|
target_date: date,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Return the list of (unit, location, project) tuples that were
|
||||||
|
actively assigned on a specific calendar date. Used for the
|
||||||
|
day-detail side panel when an operator clicks a day cell.
|
||||||
|
"""
|
||||||
|
from backend.models import MonitoringLocation, RosterUnit
|
||||||
|
|
||||||
|
day_start = datetime.combine(target_date, datetime.min.time())
|
||||||
|
day_end = datetime.combine(target_date, datetime.max.time())
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.assigned_at <= day_end)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
UnitAssignment.assigned_until >= day_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(UnitAssignment.project_id, UnitAssignment.unit_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
loc_ids = {a.location_id for a in rows}
|
||||||
|
proj_ids = {a.project_id for a in rows}
|
||||||
|
loc_map = {
|
||||||
|
l.id: l for l in db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.id.in_(loc_ids)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
proj_map = {
|
||||||
|
p.id: p for p in db.query(Project).filter(
|
||||||
|
Project.id.in_(proj_ids)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for a in rows:
|
||||||
|
loc = loc_map.get(a.location_id)
|
||||||
|
proj = proj_map.get(a.project_id)
|
||||||
|
results.append({
|
||||||
|
"assignment_id": a.id,
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"location_name": loc.name if loc else "(unknown location)",
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"project_name": proj.name if proj else "(deleted project)",
|
||||||
|
"project_color": _color_for_project(a.project_id),
|
||||||
|
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
|
||||||
|
"assigned_until": a.assigned_until.isoformat() if a.assigned_until else None,
|
||||||
|
"is_active": a.assigned_until is None,
|
||||||
|
"source": a.source,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -39,13 +39,46 @@ from backend.services.sfm_events import (
|
|||||||
_fetch_events_for_serial,
|
_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
|
||||||
|
|
||||||
|
# When detecting "mergeable" groups of consecutive same-location assignments,
|
||||||
|
# treat assignments separated by no more than this many seconds as adjacent.
|
||||||
|
# Generous enough to catch overnight handoffs and weekend gaps where the
|
||||||
|
# operator forgot to log, but tight enough that genuinely separate
|
||||||
|
# deployments months apart don't get suggested for merging.
|
||||||
|
_MERGE_GAP_TOLERANCE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||||
|
|
||||||
# Per-call timeout when querying SFM for the event overlay.
|
# Per-call timeout when querying SFM for the event overlay.
|
||||||
_SFM_TIMEOUT = 10.0
|
_SFM_TIMEOUT = 10.0
|
||||||
_SFM_FETCH_CEILING = 5000
|
_SFM_FETCH_CEILING = 5000
|
||||||
@@ -178,8 +211,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": peak_at,
|
"peak_pvs_at": _iso_local(peak_at),
|
||||||
"last_event": last_ev,
|
"last_event": _iso_local(last_ev),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||||
@@ -195,8 +228,8 @@ async def deployment_timeline_for_unit(
|
|||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"kind": "assignment",
|
"kind": "assignment",
|
||||||
"starts_at": _iso_utc(a.assigned_at),
|
"starts_at": _iso_local(a.assigned_at),
|
||||||
"ends_at": _iso_utc(a.assigned_until),
|
"ends_at": _iso_local(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,
|
||||||
@@ -220,8 +253,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_utc(gap_start),
|
"starts_at": _iso_local(gap_start),
|
||||||
"ends_at": _iso_utc(gap_end),
|
"ends_at": _iso_local(gap_end),
|
||||||
"duration_days": round(gap_seconds / 86400, 1),
|
"duration_days": round(gap_seconds / 86400, 1),
|
||||||
"context": "between assignments",
|
"context": "between assignments",
|
||||||
})
|
})
|
||||||
@@ -234,7 +267,7 @@ async def deployment_timeline_for_unit(
|
|||||||
continue
|
continue
|
||||||
entries.append({
|
entries.append({
|
||||||
"kind": "state_change",
|
"kind": "state_change",
|
||||||
"starts_at": _iso_utc(h.changed_at),
|
"starts_at": _iso_local(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,
|
||||||
@@ -245,7 +278,32 @@ async def deployment_timeline_for_unit(
|
|||||||
"history_notes": h.notes,
|
"history_notes": h.notes,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 6. Sort newest first. Active assignments (no end) sort by start time,
|
# 6. Detect mergeable groups — runs of consecutive assignments to the
|
||||||
|
# same location with small gaps between them. Each group becomes a
|
||||||
|
# list of assignment_ids; the UI offers a "Merge into one" action
|
||||||
|
# on any group >= 2.
|
||||||
|
merge_groups: list[list[str]] = []
|
||||||
|
if len(assignments) >= 2:
|
||||||
|
# Sort ascending for the linear scan.
|
||||||
|
sorted_assignments = sorted(assignments, key=lambda a: a.assigned_at)
|
||||||
|
cur_group: list[UnitAssignment] = [sorted_assignments[0]]
|
||||||
|
for a in sorted_assignments[1:]:
|
||||||
|
prev = cur_group[-1]
|
||||||
|
same_location = a.location_id == prev.location_id
|
||||||
|
prev_end = prev.assigned_until or now
|
||||||
|
gap_seconds = (a.assigned_at - prev_end).total_seconds() if a.assigned_at else 0
|
||||||
|
# Within tolerance and same location → extend the current group.
|
||||||
|
# Negative gaps (overlap) also count as adjacent.
|
||||||
|
if same_location and gap_seconds <= _MERGE_GAP_TOLERANCE_SECONDS:
|
||||||
|
cur_group.append(a)
|
||||||
|
else:
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
cur_group = [a]
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
|
||||||
|
# 7. Sort newest first. Active assignments (no end) sort by start time,
|
||||||
# same as everything else.
|
# same as everything else.
|
||||||
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
||||||
|
|
||||||
@@ -253,4 +311,8 @@ async def deployment_timeline_for_unit(
|
|||||||
"unit_id": unit.id,
|
"unit_id": unit.id,
|
||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
|
# List of assignment_id lists; each inner list is a mergeable group.
|
||||||
|
# Empty if nothing is mergeable. UI shows a "Merge" button on any
|
||||||
|
# row whose assignment_id appears in a group.
|
||||||
|
"merge_groups": merge_groups,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ def similarity(a: str, b: str) -> float:
|
|||||||
too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the
|
too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the
|
||||||
strings don't exact-match. This guardrails the 'one common word
|
strings don't exact-match. This guardrails the 'one common word
|
||||||
inside a longer phrase' false positive.
|
inside a longer phrase' false positive.
|
||||||
|
|
||||||
|
USE FOR: project names (where typos like '1-80' vs 'I-80' should
|
||||||
|
still match). For location names use `location_similarity()` —
|
||||||
|
WRatio is too lenient on the shared boilerplate vocabulary in
|
||||||
|
location strings ('Area', 'Loc', 'Bridge', 'Dam', etc.).
|
||||||
"""
|
"""
|
||||||
if not a or not b:
|
if not a or not b:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -172,6 +177,50 @@ def similarity(a: str, b: str) -> float:
|
|||||||
return rapidfuzz.fuzz.WRatio(a, b) / 100.0
|
return rapidfuzz.fuzz.WRatio(a, b) / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# Multi-digit penalty applied when two location names have completely
|
||||||
|
# disjoint multi-digit numeric tokens (e.g. "87 Jenks" vs "68 Jenks").
|
||||||
|
# Single-digit numbers ("Loc 1", "Area 2") are often shared coincidentally,
|
||||||
|
# but address-style multi-digit numbers are strong identifiers — if they
|
||||||
|
# differ, the locations are usually different physical places.
|
||||||
|
_LOCATION_DIGIT_MISMATCH_PENALTY = 0.30
|
||||||
|
|
||||||
|
|
||||||
|
def location_similarity(a: str, b: str) -> float:
|
||||||
|
"""Stricter similarity score for location-name matching.
|
||||||
|
|
||||||
|
Location names share so much boilerplate vocabulary ('Area', 'Loc',
|
||||||
|
'Bridge', 'Dam') that rapidfuzz.WRatio inflates obvious mismatches.
|
||||||
|
Example: 'Area 2 - Brookville Dam - Loc 2 East' vs 'Area 1 - Loc 1 -
|
||||||
|
87 Jenks' scores 85.5 via WRatio despite being unrelated locations.
|
||||||
|
|
||||||
|
This scorer uses `token_set_ratio` as the base (sensitive to actual
|
||||||
|
word overlap, not just substring containment). It then applies a
|
||||||
|
multi-digit penalty: if both strings contain 2+-digit numbers and
|
||||||
|
none overlap, subtract 0.30. Catches the "same project, different
|
||||||
|
address-style identifier" case ('87 Jenks' vs '68 Jenks') that pure
|
||||||
|
token-set scoring still rates above 0.90.
|
||||||
|
|
||||||
|
Single-digit numbers ('Loc 1', 'Area 2') are excluded from the
|
||||||
|
penalty because they're often shared boilerplate ("Loc 1" in every
|
||||||
|
project) rather than discriminating identifiers.
|
||||||
|
"""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
if min(len(a), len(b)) < _MIN_FUZZY_LEN:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
base = rapidfuzz.fuzz.token_set_ratio(a, b) / 100.0
|
||||||
|
|
||||||
|
multidigits_a = set(re.findall(r"\d{2,}", a))
|
||||||
|
multidigits_b = set(re.findall(r"\d{2,}", b))
|
||||||
|
if multidigits_a and multidigits_b and not (multidigits_a & multidigits_b):
|
||||||
|
base = max(0.0, base - _LOCATION_DIGIT_MISMATCH_PENALTY)
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
# ── Cluster + Suggestion dataclasses ───────────────────────────────────────────
|
# ── Cluster + Suggestion dataclasses ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -572,15 +621,24 @@ async def _scan_clusters(
|
|||||||
def _find_best_match(
|
def _find_best_match(
|
||||||
candidate_norm: str,
|
candidate_norm: str,
|
||||||
candidates: list[tuple[str, str]], # (id, normalised_name)
|
candidates: list[tuple[str, str]], # (id, normalised_name)
|
||||||
|
*,
|
||||||
|
kind: str = "project", # "project" | "location"
|
||||||
) -> tuple[Optional[str], Optional[float], str]:
|
) -> tuple[Optional[str], Optional[float], str]:
|
||||||
"""Return (best_id, best_score, classification).
|
"""Return (best_id, best_score, classification).
|
||||||
|
|
||||||
classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"}
|
classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"}
|
||||||
|
|
||||||
|
The `kind` parameter selects the scorer. Project matching uses
|
||||||
|
rapidfuzz.WRatio (lenient — catches typos like '1-80' vs 'I-80').
|
||||||
|
Location matching uses `location_similarity` (stricter — catches
|
||||||
|
boilerplate-shared-but-actually-different strings like 'Loc 2 - 68
|
||||||
|
Jenks' vs 'Loc 1 - 87 Jenks').
|
||||||
"""
|
"""
|
||||||
if not candidate_norm or not candidates:
|
if not candidate_norm or not candidates:
|
||||||
return None, None, "no_match"
|
return None, None, "no_match"
|
||||||
|
|
||||||
scored = [(cid, similarity(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
scorer = location_similarity if kind == "location" else similarity
|
||||||
|
scored = [(cid, scorer(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
||||||
scored.sort(key=lambda x: x[1], reverse=True)
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
best_id, best_score = scored[0]
|
best_id, best_score = scored[0]
|
||||||
|
|
||||||
@@ -725,7 +783,7 @@ def _build_suggestion(db: Session, cluster: Cluster) -> Suggestion:
|
|||||||
)
|
)
|
||||||
location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs]
|
location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs]
|
||||||
if cluster.location_norm:
|
if cluster.location_norm:
|
||||||
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates)
|
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates, kind="location")
|
||||||
else:
|
else:
|
||||||
loc_id, loc_score, loc_match = None, None, "create_new"
|
loc_id, loc_score, loc_match = None, None, "create_new"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
Report email sender — config-driven SMTP via the Python standard library.
|
||||||
|
|
||||||
|
Connection settings come from environment variables so the mail backend
|
||||||
|
(internal relay / Microsoft 365 / Gmail / SendGrid) can be swapped without code
|
||||||
|
changes — see the build plan: terra-mechanics.com is on M365 and has a smarthost
|
||||||
|
relay that already sends the seismograph alerts as remote@terra-mechanics.com;
|
||||||
|
reuse that relay's settings here.
|
||||||
|
|
||||||
|
DRY-RUN: if SMTP isn't configured (no host/from), the message is built and
|
||||||
|
logged but NOT sent, and the call still succeeds. This keeps report generation
|
||||||
|
working before the relay is wired up, and means a missing/incomplete mail config
|
||||||
|
can never crash the nightly pipeline.
|
||||||
|
|
||||||
|
Env vars
|
||||||
|
--------
|
||||||
|
REPORT_SMTP_HOST e.g. smtp.office365.com (unset → dry-run)
|
||||||
|
REPORT_SMTP_PORT default 587
|
||||||
|
REPORT_SMTP_SECURITY starttls (default) | ssl | none
|
||||||
|
REPORT_SMTP_USER optional — omit for IP-authenticated relays
|
||||||
|
REPORT_SMTP_PASSWORD optional
|
||||||
|
REPORT_SMTP_FROM e.g. "TMI Monitoring <monitoring@terra-mechanics.com>"
|
||||||
|
REPORT_SMTP_RECIPIENTS comma-separated default recipient list
|
||||||
|
REPORT_SMTP_TIMEOUT seconds, default 30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Convenient MIME type for the Excel attachment.
|
||||||
|
XLSX_MIME = ("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Attachment:
|
||||||
|
filename: str
|
||||||
|
content: bytes
|
||||||
|
maintype: str = "application"
|
||||||
|
subtype: str = "octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SMTPConfig:
|
||||||
|
host: str = ""
|
||||||
|
port: int = 587
|
||||||
|
security: str = "starttls" # "starttls" | "ssl" | "none"
|
||||||
|
user: str = ""
|
||||||
|
password: str = ""
|
||||||
|
sender: str = ""
|
||||||
|
recipients: list[str] = field(default_factory=list)
|
||||||
|
timeout: float = 30.0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "SMTPConfig":
|
||||||
|
rec = os.getenv("REPORT_SMTP_RECIPIENTS", "")
|
||||||
|
return cls(
|
||||||
|
host=os.getenv("REPORT_SMTP_HOST", "").strip(),
|
||||||
|
port=int(os.getenv("REPORT_SMTP_PORT", "587") or 587),
|
||||||
|
security=os.getenv("REPORT_SMTP_SECURITY", "starttls").strip().lower(),
|
||||||
|
user=os.getenv("REPORT_SMTP_USER", "").strip(),
|
||||||
|
password=os.getenv("REPORT_SMTP_PASSWORD", ""),
|
||||||
|
sender=os.getenv("REPORT_SMTP_FROM", "").strip(),
|
||||||
|
recipients=[r.strip() for r in rec.split(",") if r.strip()],
|
||||||
|
timeout=float(os.getenv("REPORT_SMTP_TIMEOUT", "30") or 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
"""True only when we have enough to actually send (host + from)."""
|
||||||
|
return bool(self.host and self.sender)
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(
|
||||||
|
cfg: SMTPConfig,
|
||||||
|
subject: str,
|
||||||
|
html_body: str,
|
||||||
|
recipients: list[str],
|
||||||
|
attachments: Optional[list[Attachment]] = None,
|
||||||
|
text_body: Optional[str] = None,
|
||||||
|
) -> EmailMessage:
|
||||||
|
"""Assemble a multipart message: plain-text fallback + HTML + attachments."""
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = cfg.sender or "terra-view@localhost"
|
||||||
|
msg["To"] = ", ".join(recipients)
|
||||||
|
msg["Subject"] = subject
|
||||||
|
# Plain-text part first, then the HTML alternative (clients prefer the HTML).
|
||||||
|
msg.set_content(text_body or "This report is best viewed in an HTML email client.")
|
||||||
|
msg.add_alternative(html_body, subtype="html")
|
||||||
|
for att in (attachments or []):
|
||||||
|
msg.add_attachment(
|
||||||
|
att.content, maintype=att.maintype, subtype=att.subtype, filename=att.filename,
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def send_report_email(
|
||||||
|
subject: str,
|
||||||
|
html_body: str,
|
||||||
|
*,
|
||||||
|
attachments: Optional[list[Attachment]] = None,
|
||||||
|
recipients: Optional[list[str]] = None,
|
||||||
|
text_body: Optional[str] = None,
|
||||||
|
cfg: Optional[SMTPConfig] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Send (or dry-run) the report email.
|
||||||
|
|
||||||
|
Returns a result dict: {sent, dry_run, recipients, error}. Never raises on
|
||||||
|
a send failure — it logs and returns error, so the orchestrator can record
|
||||||
|
the failure without aborting the rest of the pipeline.
|
||||||
|
"""
|
||||||
|
cfg = cfg or SMTPConfig.from_env()
|
||||||
|
recipients = recipients if recipients is not None else cfg.recipients
|
||||||
|
result = {"sent": False, "dry_run": False, "recipients": recipients, "error": None}
|
||||||
|
|
||||||
|
if not recipients:
|
||||||
|
result["error"] = "No recipients configured"
|
||||||
|
logger.warning("Report email: no recipients set; skipping send of %r", subject)
|
||||||
|
return result
|
||||||
|
|
||||||
|
msg = build_message(cfg, subject, html_body, recipients, attachments, text_body)
|
||||||
|
|
||||||
|
if not cfg.configured:
|
||||||
|
result["dry_run"] = True
|
||||||
|
logger.info(
|
||||||
|
"Report email DRY-RUN (SMTP not configured): would send %r to %s with %d attachment(s)",
|
||||||
|
subject, recipients, len(attachments or []),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Validate the security mode: an unrecognized value (typo) must NOT silently
|
||||||
|
# fall through to a plaintext connection while still sending credentials.
|
||||||
|
sec = cfg.security if cfg.security in ("ssl", "starttls", "none") else "starttls"
|
||||||
|
if sec != cfg.security:
|
||||||
|
logger.warning("Unknown REPORT_SMTP_SECURITY=%r — falling back to 'starttls'", cfg.security)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if sec == "ssl":
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=cfg.timeout, context=ctx) as s:
|
||||||
|
if cfg.user:
|
||||||
|
s.login(cfg.user, cfg.password)
|
||||||
|
s.send_message(msg)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(cfg.host, cfg.port, timeout=cfg.timeout) as s:
|
||||||
|
s.ehlo()
|
||||||
|
if sec == "starttls":
|
||||||
|
s.starttls(context=ssl.create_default_context())
|
||||||
|
s.ehlo()
|
||||||
|
if cfg.user:
|
||||||
|
if sec == "none":
|
||||||
|
logger.warning(
|
||||||
|
"Sending SMTP credentials over an UNENCRYPTED connection "
|
||||||
|
"(REPORT_SMTP_SECURITY=none) — set starttls/ssl if the relay supports it."
|
||||||
|
)
|
||||||
|
s.login(cfg.user, cfg.password)
|
||||||
|
s.send_message(msg)
|
||||||
|
result["sent"] = True
|
||||||
|
logger.info("Report email sent: %r to %s", subject, recipients)
|
||||||
|
except Exception as e: # noqa: BLE001 — surface as result, never abort the pipeline
|
||||||
|
result["error"] = str(e)
|
||||||
|
logger.error("Report email send failed: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Nightly Report Orchestrator.
|
||||||
|
|
||||||
|
Ties the pieces together: compute → render → write-to-disk → email.
|
||||||
|
|
||||||
|
This is what the daily cycle (or a manual trigger) calls. It ALWAYS writes the
|
||||||
|
rendered report to disk — `data/reports/{project_id}/{night_date}/report.html`
|
||||||
|
(+ `report.json` with the raw numbers) — so there's a viewable artifact even
|
||||||
|
when email is in dry-run (SMTP not configured yet). The email step is
|
||||||
|
best-effort and never aborts the run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.services.report_pipeline import (
|
||||||
|
ProjectNightReport, build_project_night_report, Window,
|
||||||
|
)
|
||||||
|
from backend.services.report_renderers import render_html_summary, render_excel
|
||||||
|
from backend.services.report_email import send_report_email, Attachment, XLSX_MIME
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_OUTPUT_ROOT = "data/reports"
|
||||||
|
|
||||||
|
|
||||||
|
def _report_to_dict(report: ProjectNightReport) -> dict:
|
||||||
|
"""Serialise the report data model to plain JSON (for the on-disk record)."""
|
||||||
|
return {
|
||||||
|
"project_id": report.project_id,
|
||||||
|
"project_name": report.project_name,
|
||||||
|
"night_date": report.night_date.isoformat(),
|
||||||
|
"metrics": [m.key for m in report.metrics],
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"name": loc.location_name,
|
||||||
|
"night_interval_count": loc.night_interval_count,
|
||||||
|
"baseline_nights_used": loc.baseline_nights_used,
|
||||||
|
"notes": loc.notes,
|
||||||
|
"windows": {
|
||||||
|
w.key: {
|
||||||
|
"label": w.label,
|
||||||
|
"metrics": {
|
||||||
|
m.key: {
|
||||||
|
"label": m.label,
|
||||||
|
"last_night": loc.table[w.key][m.key].last_night,
|
||||||
|
"baseline": loc.table[w.key][m.key].baseline,
|
||||||
|
"delta": loc.table[w.key][m.key].delta,
|
||||||
|
}
|
||||||
|
for m in loc.metrics
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for w in loc.windows
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for loc in report.locations
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_nightly_report(
|
||||||
|
db: Session,
|
||||||
|
project_id: str,
|
||||||
|
night_date: date,
|
||||||
|
*,
|
||||||
|
metric_keys: Optional[list[str]] = None,
|
||||||
|
windows: Optional[list[Window]] = None,
|
||||||
|
baseline_mode: str = "captured",
|
||||||
|
baseline_start: Optional[date] = None,
|
||||||
|
baseline_end: Optional[date] = None,
|
||||||
|
recipients: Optional[list[str]] = None,
|
||||||
|
output_root: str = DEFAULT_OUTPUT_ROOT,
|
||||||
|
send: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Build, persist, and (dry-run) email the night report for a project.
|
||||||
|
|
||||||
|
Returns a result dict with the on-disk artifact paths and the email result.
|
||||||
|
Designed to be called from the daily cycle or a manual trigger.
|
||||||
|
"""
|
||||||
|
report = build_project_night_report(
|
||||||
|
db, project_id, night_date,
|
||||||
|
metric_keys=metric_keys, windows=windows,
|
||||||
|
baseline_mode=baseline_mode,
|
||||||
|
baseline_start=baseline_start, baseline_end=baseline_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = render_html_summary(report)
|
||||||
|
subject = f"{report.project_name} — night report {night_date:%m/%d/%y}"
|
||||||
|
|
||||||
|
# --- Always persist a viewable copy ---
|
||||||
|
out_dir = Path(output_root) / project_id / f"{night_date:%Y-%m-%d}"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
html_path = out_dir / "report.html"
|
||||||
|
html_path.write_text(html, encoding="utf-8")
|
||||||
|
json_path = out_dir / "report.json"
|
||||||
|
json_path.write_text(json.dumps(_report_to_dict(report), indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
# --- Excel (the email attachment; also written to disk for the archive) ---
|
||||||
|
attachments: list[Attachment] = []
|
||||||
|
xlsx_path = None
|
||||||
|
try:
|
||||||
|
xlsx_bytes = render_excel(report)
|
||||||
|
xlsx_path = out_dir / "report.xlsx"
|
||||||
|
xlsx_path.write_bytes(xlsx_bytes)
|
||||||
|
safe_name = "".join(c for c in report.project_name if c.isalnum() or c in " -_").strip().replace(" ", "_")
|
||||||
|
attachments.append(Attachment(
|
||||||
|
f"{safe_name or 'report'}_{night_date:%Y-%m-%d}_night_report.xlsx",
|
||||||
|
xlsx_bytes, *XLSX_MIME,
|
||||||
|
))
|
||||||
|
except Exception as e: # noqa: BLE001 — never let the spreadsheet sink the report
|
||||||
|
logger.error("Excel render failed for %s (%s): %s", project_id, night_date, e, exc_info=True)
|
||||||
|
|
||||||
|
# --- Email (best-effort; dry-run until SMTP is configured) ---
|
||||||
|
email_result = {"sent": False, "dry_run": False, "skipped": True, "error": None}
|
||||||
|
if send:
|
||||||
|
try:
|
||||||
|
email_result = send_report_email(
|
||||||
|
subject, html, attachments=attachments, recipients=recipients,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — artifacts are already written; never abort on email
|
||||||
|
logger.error("send_report_email raised for %s (%s): %s", project_id, night_date, e, exc_info=True)
|
||||||
|
email_result = {"sent": False, "dry_run": False, "skipped": False, "error": str(e)}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_name": report.project_name,
|
||||||
|
"night_date": night_date.isoformat(),
|
||||||
|
"subject": subject,
|
||||||
|
"location_count": len(report.locations),
|
||||||
|
"html_path": str(html_path),
|
||||||
|
"json_path": str(json_path),
|
||||||
|
"xlsx_path": str(xlsx_path) if xlsx_path else None,
|
||||||
|
"html": html, # for callers that want to display it inline
|
||||||
|
"email": email_result,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"Nightly report for %s (%s): %d location(s) → %s; email=%s",
|
||||||
|
report.project_name, night_date, len(report.locations), html_path,
|
||||||
|
"sent" if email_result.get("sent") else
|
||||||
|
("dry-run" if email_result.get("dry_run") else
|
||||||
|
("skipped" if email_result.get("skipped") else f"error: {email_result.get('error')}")),
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"""
|
||||||
|
Nightly Report Pipeline — computation core.
|
||||||
|
|
||||||
|
Builds the data model for the John-Myler-style "last night vs. baseline" sound
|
||||||
|
report. Source-agnostic: it reads the same on-disk Leq `.rnd` files the manual
|
||||||
|
upload + FTP-pull ingest produce (see `project_locations.ingest_nrl_zip`).
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
* **Ingest everything, report selectively.** Ingest preserves every column of
|
||||||
|
the Leq file; this layer chooses which *metrics* to surface via `metric_keys`
|
||||||
|
(a future report wizard is just a UI over that list).
|
||||||
|
* **House format match.** Defaults reproduce the existing Excel report:
|
||||||
|
LAmax (max of interval maxima), LA01 / LA10 (arithmetic average), split into
|
||||||
|
Evening (7–10PM) and Nighttime (10PM–7AM) windows. L90 (background) is added
|
||||||
|
for the baseline comparison.
|
||||||
|
* **Metric labelling from the device.** The LN→percentile assignment is
|
||||||
|
reconfigurable per job; we resolve which `LNx(Main)` column is L90/L10/etc.
|
||||||
|
from the percentile map captured in the session metadata at ingest, falling
|
||||||
|
back to the NL-43 default order.
|
||||||
|
* **Correct averaging.** Leq is energy-averaged (logarithmic); percentiles and
|
||||||
|
Lmax are arithmetic. Baseline references combine the per-night values into a
|
||||||
|
"typical night" (arithmetic mean of per-night values — so baseline Lmax is the
|
||||||
|
typical nightly peak, not the worst-of-week).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.models import MonitoringSession, DataFile, MonitoringLocation, Project
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metric registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Metric:
|
||||||
|
"""A reportable metric.
|
||||||
|
|
||||||
|
`agg` is the *within-night* aggregation used to collapse a window's 15-min
|
||||||
|
intervals into one value:
|
||||||
|
- "max" → loudest interval (LAmax)
|
||||||
|
- "arith" → arithmetic mean (percentiles: L01/L10/L90…)
|
||||||
|
- "log" → energy/logarithmic mean (Leq only)
|
||||||
|
`column` pins a fixed .rnd column; `percentile` instead resolves the LNx
|
||||||
|
column from the session's captured percentile map.
|
||||||
|
"""
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
agg: str
|
||||||
|
column: Optional[str] = None
|
||||||
|
percentile: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
METRIC_REGISTRY: dict[str, Metric] = {
|
||||||
|
"lmax": Metric("lmax", "LAmax", "max", column="Lmax(Main)"),
|
||||||
|
"leq": Metric("leq", "LAeq", "log", column="Leq(Main)"),
|
||||||
|
"lmin": Metric("lmin", "LAmin", "arith", column="Lmin(Main)"),
|
||||||
|
"l01": Metric("l01", "LA01", "arith", percentile=1.0),
|
||||||
|
"l10": Metric("l10", "LA10", "arith", percentile=10.0),
|
||||||
|
"l50": Metric("l50", "LA50", "arith", percentile=50.0),
|
||||||
|
"l90": Metric("l90", "LA90", "arith", percentile=90.0),
|
||||||
|
"l95": Metric("l95", "LA95", "arith", percentile=95.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# House report metrics + L90 (background) for the baseline comparison.
|
||||||
|
DEFAULT_METRICS: list[str] = ["lmax", "l01", "l10", "l90"]
|
||||||
|
|
||||||
|
# NL-43 default percentile→slot assignment, used when a session has no captured map.
|
||||||
|
_DEFAULT_SLOT_FOR_PCT: dict[float, int] = {1.0: 1, 10.0: 2, 50.0: 3, 90.0: 4, 95.0: 5}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_column(metric: Metric, pct_map: dict) -> Optional[str]:
|
||||||
|
"""Resolve the .rnd column for a metric, using the session's percentile map."""
|
||||||
|
if metric.column:
|
||||||
|
return metric.column
|
||||||
|
if metric.percentile is None:
|
||||||
|
return None
|
||||||
|
# pct_map: {"1": "1.0", "2": "10.0", "4": "90.0", ...} → slot : percentile
|
||||||
|
if pct_map:
|
||||||
|
for slot, pval in pct_map.items():
|
||||||
|
try:
|
||||||
|
if float(pval) == metric.percentile:
|
||||||
|
return f"LN{int(slot)}(Main)"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
slot = _DEFAULT_SLOT_FOR_PCT.get(metric.percentile)
|
||||||
|
return f"LN{slot}(Main)" if slot else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Time windows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Window:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
start_hour: int
|
||||||
|
end_hour: int
|
||||||
|
|
||||||
|
def contains(self, hour: int) -> bool:
|
||||||
|
if self.start_hour < self.end_hour:
|
||||||
|
return self.start_hour <= hour < self.end_hour
|
||||||
|
return hour >= self.start_hour or hour < self.end_hour
|
||||||
|
|
||||||
|
|
||||||
|
# Matches the existing Excel report's stats table.
|
||||||
|
DEFAULT_WINDOWS: list[Window] = [
|
||||||
|
Window("evening", "Evening (7PM–10PM)", 19, 22),
|
||||||
|
Window("nighttime", "Nighttime (10PM–7AM)", 22, 7),
|
||||||
|
]
|
||||||
|
|
||||||
|
# The full night used to select which intervals belong to "last night".
|
||||||
|
NIGHT_START_HOUR = 19
|
||||||
|
NIGHT_LENGTH_HOURS = 12
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Aggregation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _aggregate(values: list, method: str) -> Optional[float]:
|
||||||
|
"""Collapse a window's interval values into one number per `method`."""
|
||||||
|
vals = [v for v in values if isinstance(v, (int, float))]
|
||||||
|
if not vals:
|
||||||
|
return None
|
||||||
|
if method == "max":
|
||||||
|
return round(max(vals), 1)
|
||||||
|
if method == "log":
|
||||||
|
return round(10 * math.log10(sum(10 ** (v / 10.0) for v in vals) / len(vals)), 1)
|
||||||
|
return round(sum(vals) / len(vals), 1) # arithmetic
|
||||||
|
|
||||||
|
|
||||||
|
def _combine_across_nights(per_night: list, method: str) -> Optional[float]:
|
||||||
|
"""Combine per-night window values into a baseline 'typical night' value.
|
||||||
|
|
||||||
|
Arithmetic mean for max/arith metrics (so baseline Lmax = typical nightly
|
||||||
|
peak, the agreed default), logarithmic mean for Leq.
|
||||||
|
"""
|
||||||
|
vals = [v for v in per_night if v is not None]
|
||||||
|
if not vals:
|
||||||
|
return None
|
||||||
|
if method == "log":
|
||||||
|
return round(10 * math.log10(sum(10 ** (v / 10.0) for v in vals) / len(vals)), 1)
|
||||||
|
return round(sum(vals) / len(vals), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Row gathering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_dt(s: str) -> Optional[datetime]:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, "%Y/%m/%d %H:%M:%S")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _location_leq_rows(db: Session, location_id: str) -> list[tuple[datetime, dict, dict]]:
|
||||||
|
"""All Leq intervals at a location as (interval_dt, row, percentile_map).
|
||||||
|
|
||||||
|
Reuses the same .rnd readers as the report endpoints so parsing stays
|
||||||
|
identical. Times are the meter's local clock (as written in the file).
|
||||||
|
"""
|
||||||
|
# Lazy import avoids a service→router import cycle at module load.
|
||||||
|
from backend.routers.projects import (
|
||||||
|
_read_rnd_file_rows, _normalize_rnd_rows, _is_leq_file, _peek_rnd_headers,
|
||||||
|
)
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
out: list[tuple[datetime, dict, dict]] = []
|
||||||
|
sessions = db.query(MonitoringSession).filter_by(
|
||||||
|
location_id=location_id, session_type="sound",
|
||||||
|
).all()
|
||||||
|
for s in sessions:
|
||||||
|
try:
|
||||||
|
meta = json.loads(s.session_metadata or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
meta = {}
|
||||||
|
pct_map = meta.get("percentiles", {}) or {}
|
||||||
|
for f in db.query(DataFile).filter_by(session_id=s.id).all():
|
||||||
|
if not f.file_path or not f.file_path.lower().endswith(".rnd"):
|
||||||
|
continue
|
||||||
|
peek = _peek_rnd_headers(Path("data") / f.file_path)
|
||||||
|
if not _is_leq_file(f.file_path, peek):
|
||||||
|
continue
|
||||||
|
rows = _read_rnd_file_rows(f.file_path)
|
||||||
|
rows, _ = _normalize_rnd_rows(rows)
|
||||||
|
for r in rows:
|
||||||
|
dt = _parse_dt(r.get("Start Time", ""))
|
||||||
|
if dt:
|
||||||
|
out.append((dt, r, pct_map))
|
||||||
|
out.sort(key=lambda t: t[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_in_night(rows: list, night_date: date) -> list:
|
||||||
|
"""Rows falling in the night that *starts* on night_date (19:00 → +12h)."""
|
||||||
|
start = datetime(night_date.year, night_date.month, night_date.day, NIGHT_START_HOUR, 0)
|
||||||
|
end = start + timedelta(hours=NIGHT_LENGTH_HOURS)
|
||||||
|
return [(dt, r, p) for (dt, r, p) in rows if start <= dt < end]
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_nights(rows: list, start_date: date, end_date: date) -> list[date]:
|
||||||
|
"""Evening-dates in [start_date, end_date] that actually have night data."""
|
||||||
|
nights = []
|
||||||
|
cur = start_date
|
||||||
|
while cur <= end_date:
|
||||||
|
if _rows_in_night(rows, cur):
|
||||||
|
nights.append(cur)
|
||||||
|
cur += timedelta(days=1)
|
||||||
|
return nights
|
||||||
|
|
||||||
|
|
||||||
|
def _window_value(rows: list, metric: Metric, window: Window) -> Optional[float]:
|
||||||
|
"""Single aggregated value for one metric over one window of `rows`."""
|
||||||
|
vals = []
|
||||||
|
for dt, r, pct_map in rows:
|
||||||
|
if window.contains(dt.hour):
|
||||||
|
col = _resolve_column(metric, pct_map)
|
||||||
|
if col:
|
||||||
|
vals.append(r.get(col))
|
||||||
|
return _aggregate(vals, metric.agg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Report data model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CellPair:
|
||||||
|
last_night: Optional[float]
|
||||||
|
baseline: Optional[float]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delta(self) -> Optional[float]:
|
||||||
|
if self.last_night is None or self.baseline is None:
|
||||||
|
return None
|
||||||
|
return round(self.last_night - self.baseline, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LocationNightReport:
|
||||||
|
location_id: str
|
||||||
|
location_name: str
|
||||||
|
night_date: date
|
||||||
|
metrics: list[Metric]
|
||||||
|
windows: list[Window]
|
||||||
|
# table[window_key][metric_key] = CellPair
|
||||||
|
table: dict[str, dict[str, CellPair]]
|
||||||
|
interval_series: list[dict]
|
||||||
|
night_interval_count: int
|
||||||
|
baseline_nights_used: int
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _location_reference_baseline(loc) -> dict:
|
||||||
|
"""A location's manually-entered reference baseline, from its metadata.
|
||||||
|
|
||||||
|
Shape: {window_key: {metric_key: float}} e.g. {"nighttime": {"l10": 85.0}}.
|
||||||
|
Used when baseline_mode == "reference" — fixed targets/limits or prior-report
|
||||||
|
averages typed in, rather than computed from captured nights.
|
||||||
|
"""
|
||||||
|
if not loc:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
meta = json.loads(loc.location_metadata or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
ref = meta.get("report_baseline") or {}
|
||||||
|
out: dict[str, dict[str, float]] = {}
|
||||||
|
if isinstance(ref, dict):
|
||||||
|
for wkey, mvals in ref.items():
|
||||||
|
if not isinstance(mvals, dict):
|
||||||
|
continue
|
||||||
|
clean = {}
|
||||||
|
for mkey, val in mvals.items():
|
||||||
|
try:
|
||||||
|
clean[mkey] = float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if clean:
|
||||||
|
out[wkey] = clean
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_location_night_report(
|
||||||
|
db: Session,
|
||||||
|
location_id: str,
|
||||||
|
night_date: date,
|
||||||
|
*,
|
||||||
|
metric_keys: Optional[list[str]] = None,
|
||||||
|
windows: Optional[list[Window]] = None,
|
||||||
|
baseline_mode: str = "captured",
|
||||||
|
baseline_start: Optional[date] = None,
|
||||||
|
baseline_end: Optional[date] = None,
|
||||||
|
) -> LocationNightReport:
|
||||||
|
"""Build the night-vs-baseline data model for one location.
|
||||||
|
|
||||||
|
`night_date` is the *evening* date of the night being reported (e.g. the
|
||||||
|
7/7 in "night of 7/7 → morning 7/8"). Baseline comes from one of:
|
||||||
|
- "captured": the typical-night value across eligible nights in
|
||||||
|
[baseline_start, baseline_end] (computed from recorded data);
|
||||||
|
- "reference": fixed values typed per location (a spec limit like
|
||||||
|
"L10 = 85", or a prior report's averages).
|
||||||
|
"""
|
||||||
|
metric_keys = metric_keys or DEFAULT_METRICS
|
||||||
|
metrics = [METRIC_REGISTRY[k] for k in metric_keys]
|
||||||
|
windows = windows or DEFAULT_WINDOWS
|
||||||
|
|
||||||
|
loc = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||||||
|
loc_name = loc.name if loc else location_id
|
||||||
|
|
||||||
|
all_rows = _location_leq_rows(db, location_id)
|
||||||
|
night_rows = _rows_in_night(all_rows, night_date)
|
||||||
|
|
||||||
|
reference = _location_reference_baseline(loc) if baseline_mode == "reference" else {}
|
||||||
|
|
||||||
|
baseline_nights: list[date] = []
|
||||||
|
if baseline_mode != "reference" and baseline_start and baseline_end:
|
||||||
|
baseline_nights = _eligible_nights(all_rows, baseline_start, baseline_end)
|
||||||
|
# Don't let the reported night double as its own baseline.
|
||||||
|
baseline_nights = [n for n in baseline_nights if n != night_date]
|
||||||
|
|
||||||
|
table: dict[str, dict[str, CellPair]] = {}
|
||||||
|
for w in windows:
|
||||||
|
table[w.key] = {}
|
||||||
|
for m in metrics:
|
||||||
|
last_night_val = _window_value(night_rows, m, w)
|
||||||
|
if baseline_mode == "reference":
|
||||||
|
baseline_val = reference.get(w.key, {}).get(m.key)
|
||||||
|
elif baseline_nights:
|
||||||
|
per_night = [
|
||||||
|
_window_value(_rows_in_night(all_rows, nd), m, w)
|
||||||
|
for nd in baseline_nights
|
||||||
|
]
|
||||||
|
baseline_val = _combine_across_nights(per_night, m.agg)
|
||||||
|
else:
|
||||||
|
baseline_val = None
|
||||||
|
table[w.key][m.key] = CellPair(last_night_val, baseline_val)
|
||||||
|
|
||||||
|
interval_series = []
|
||||||
|
for dt, r, pct_map in night_rows:
|
||||||
|
entry = {"dt": dt, "time": dt.strftime("%H:%M")}
|
||||||
|
for m in metrics:
|
||||||
|
col = _resolve_column(m, pct_map)
|
||||||
|
val = r.get(col) if col else None
|
||||||
|
entry[m.key] = val if isinstance(val, (int, float)) else None
|
||||||
|
interval_series.append(entry)
|
||||||
|
|
||||||
|
notes: list[str] = []
|
||||||
|
if not night_rows:
|
||||||
|
notes.append(f"No data found for the night of {night_date:%m/%d/%y}.")
|
||||||
|
if baseline_mode == "reference":
|
||||||
|
if not any(reference.values()):
|
||||||
|
notes.append("Reference-baseline mode is on but no reference values are set for this location.")
|
||||||
|
elif (baseline_start or baseline_end) and not baseline_nights:
|
||||||
|
notes.append("No baseline nights with data in the configured range.")
|
||||||
|
|
||||||
|
return LocationNightReport(
|
||||||
|
location_id=location_id,
|
||||||
|
location_name=loc_name,
|
||||||
|
night_date=night_date,
|
||||||
|
metrics=metrics,
|
||||||
|
windows=windows,
|
||||||
|
table=table,
|
||||||
|
interval_series=interval_series,
|
||||||
|
night_interval_count=len(night_rows),
|
||||||
|
baseline_nights_used=len(baseline_nights),
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectNightReport:
|
||||||
|
project_id: str
|
||||||
|
project_name: str
|
||||||
|
night_date: date
|
||||||
|
metrics: list[Metric]
|
||||||
|
locations: list[LocationNightReport]
|
||||||
|
|
||||||
|
|
||||||
|
def build_project_night_report(
|
||||||
|
db: Session,
|
||||||
|
project_id: str,
|
||||||
|
night_date: date,
|
||||||
|
*,
|
||||||
|
metric_keys: Optional[list[str]] = None,
|
||||||
|
windows: Optional[list[Window]] = None,
|
||||||
|
baseline_mode: str = "captured",
|
||||||
|
baseline_start: Optional[date] = None,
|
||||||
|
baseline_end: Optional[date] = None,
|
||||||
|
) -> ProjectNightReport:
|
||||||
|
"""Build the night report for every active sound location in a project."""
|
||||||
|
metric_keys = metric_keys or DEFAULT_METRICS
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
project_name = project.name if project else project_id
|
||||||
|
|
||||||
|
locations = db.query(MonitoringLocation).filter_by(
|
||||||
|
project_id=project_id, location_type="sound",
|
||||||
|
).order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
locations = [l for l in locations if getattr(l, "removed_at", None) is None]
|
||||||
|
|
||||||
|
reports = [
|
||||||
|
build_location_night_report(
|
||||||
|
db, loc.id, night_date,
|
||||||
|
metric_keys=metric_keys, windows=windows,
|
||||||
|
baseline_mode=baseline_mode,
|
||||||
|
baseline_start=baseline_start, baseline_end=baseline_end,
|
||||||
|
)
|
||||||
|
for loc in locations
|
||||||
|
]
|
||||||
|
|
||||||
|
return ProjectNightReport(
|
||||||
|
project_id=project_id,
|
||||||
|
project_name=project_name,
|
||||||
|
night_date=night_date,
|
||||||
|
metrics=[METRIC_REGISTRY[k] for k in metric_keys],
|
||||||
|
locations=reports,
|
||||||
|
)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Nightly Report Renderers.
|
||||||
|
|
||||||
|
Pluggable renderers over the `report_pipeline` data model. v1 ships the HTML
|
||||||
|
email body + the Excel attachment; PDF and an inline chart image are v1.1
|
||||||
|
(each needs a new dependency). Keeping renderers separate from the compute
|
||||||
|
core means a future report wizard just toggles metrics/renderers — the data
|
||||||
|
model is unchanged.
|
||||||
|
|
||||||
|
Email-client constraints: the HTML uses a table layout with **inline styles
|
||||||
|
only** (no <style> blocks, no external CSS, no fl/grid), which is the reliable
|
||||||
|
common denominator across Outlook / Gmail / Apple Mail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
from backend.services.report_pipeline import ProjectNightReport, LocationNightReport
|
||||||
|
|
||||||
|
|
||||||
|
# Colours: louder-than-baseline reads as a concern (red), quieter as fine (green).
|
||||||
|
_RED = "#b00020"
|
||||||
|
_GREEN = "#1a7f37"
|
||||||
|
_GREY = "#888888"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_value(v) -> str:
|
||||||
|
return f"{v:.1f}" if isinstance(v, (int, float)) else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_delta(v) -> str:
|
||||||
|
"""Signed delta with colour; positive (louder) = red, negative (quieter) = green."""
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
return f'<span style="color:{_GREY}">—</span>'
|
||||||
|
if v > 0:
|
||||||
|
return f'<span style="color:{_RED}">+{v:.1f}</span>'
|
||||||
|
if v < 0:
|
||||||
|
return f'<span style="color:{_GREEN}">{v:.1f}</span>'
|
||||||
|
return f'<span style="color:{_GREY}">0.0</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _location_table(loc: LocationNightReport) -> str:
|
||||||
|
"""One location block: heading + Metric × (window: Last / Base / Δ) table."""
|
||||||
|
th = ('padding:5px 9px;border:1px solid #ccc;background:#f2f2f2;'
|
||||||
|
'font:bold 12px Arial,sans-serif;text-align:center')
|
||||||
|
sub = ('padding:4px 8px;border:1px solid #ccc;background:#fafafa;'
|
||||||
|
'font:11px Arial,sans-serif;text-align:center;color:#555')
|
||||||
|
td = 'padding:4px 9px;border:1px solid #ccc;font:12px Arial,sans-serif;text-align:center'
|
||||||
|
td_l = 'padding:4px 9px;border:1px solid #ccc;font:bold 12px Arial,sans-serif;text-align:left'
|
||||||
|
|
||||||
|
# Top header: blank label cell + each window spanning Last/Base/Δ
|
||||||
|
top = f'<th rowspan="2" style="{th}">Metric (dBA)</th>'
|
||||||
|
for w in loc.windows:
|
||||||
|
top += f'<th colspan="3" style="{th}">{escape(w.label)}</th>'
|
||||||
|
sub_row = ''.join(
|
||||||
|
f'<th style="{sub}">Last</th><th style="{sub}">Base</th><th style="{sub}">Δ</th>'
|
||||||
|
for _ in loc.windows
|
||||||
|
)
|
||||||
|
|
||||||
|
body = ''
|
||||||
|
for m in loc.metrics:
|
||||||
|
cells = ''
|
||||||
|
for w in loc.windows:
|
||||||
|
cp = loc.table[w.key][m.key]
|
||||||
|
cells += (f'<td style="{td}">{_fmt_value(cp.last_night)}</td>'
|
||||||
|
f'<td style="{td}">{_fmt_value(cp.baseline)}</td>'
|
||||||
|
f'<td style="{td}">{_fmt_delta(cp.delta)}</td>')
|
||||||
|
body += f'<tr><td style="{td_l}">{escape(m.label)}</td>{cells}</tr>'
|
||||||
|
|
||||||
|
meta = (f'{loc.night_interval_count} intervals'
|
||||||
|
+ (f' · baseline = {loc.baseline_nights_used} night(s)'
|
||||||
|
if loc.baseline_nights_used else ' · no baseline yet'))
|
||||||
|
notes = ''
|
||||||
|
if loc.notes:
|
||||||
|
notes = ('<div style="font:11px Arial,sans-serif;color:#b00020;margin:2px 0 0">'
|
||||||
|
+ '<br>'.join(escape(n) for n in loc.notes) + '</div>')
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<h3 style="font:bold 15px Arial,sans-serif;margin:18px 0 4px">{escape(loc.location_name)}</h3>'
|
||||||
|
f'<div style="font:11px Arial,sans-serif;color:#666;margin:0 0 6px">{escape(meta)}</div>'
|
||||||
|
f'<table style="border-collapse:collapse;border:1px solid #ccc">'
|
||||||
|
f'<thead><tr>{top}</tr><tr>{sub_row}</tr></thead>'
|
||||||
|
f'<tbody>{body}</tbody></table>{notes}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_html_summary(report: ProjectNightReport) -> str:
|
||||||
|
"""Render the full email-body HTML for a project's night report."""
|
||||||
|
windows_desc = ", ".join(w.label for w in (report.locations[0].windows if report.locations else []))
|
||||||
|
header = (
|
||||||
|
f'<h2 style="font:bold 18px Arial,sans-serif;margin:0 0 2px">'
|
||||||
|
f'{escape(report.project_name)} — Night Report</h2>'
|
||||||
|
f'<div style="font:13px Arial,sans-serif;color:#444;margin:0 0 4px">'
|
||||||
|
f'Night of {report.night_date:%a %m/%d/%y} · last night vs. baseline</div>'
|
||||||
|
f'<div style="font:11px Arial,sans-serif;color:#888;margin:0 0 10px">'
|
||||||
|
f'Windows: {escape(windows_desc)}. '
|
||||||
|
f'Δ = last night minus baseline (<span style="color:{_RED}">+ louder</span>, '
|
||||||
|
f'<span style="color:{_GREEN}">− quieter</span>). '
|
||||||
|
f'LAmax = loudest interval; L-values are arithmetic averages; '
|
||||||
|
f'baseline = typical night.</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not report.locations:
|
||||||
|
body = ('<div style="font:13px Arial,sans-serif;color:#b00020">'
|
||||||
|
'No sound locations found for this project.</div>')
|
||||||
|
else:
|
||||||
|
body = ''.join(_location_table(loc) for loc in report.locations)
|
||||||
|
|
||||||
|
footer = ('<div style="font:10px Arial,sans-serif;color:#aaa;margin-top:18px">'
|
||||||
|
'Automated report — Terra-View. Full interval data in the attached spreadsheet.</div>')
|
||||||
|
|
||||||
|
return (f'<!DOCTYPE html><html><body style="margin:0;padding:16px;background:#fff">'
|
||||||
|
f'{header}{body}{footer}</body></html>')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Excel renderer (the email attachment) — one sheet per location:
|
||||||
|
# interval table + line chart + a Last/Baseline/Δ summary per window.
|
||||||
|
# Metric-driven, so it adapts to whatever metric set is configured.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _safe_sheet_name(name: str) -> str:
|
||||||
|
bad = set('[]:*?/\\')
|
||||||
|
cleaned = "".join(c for c in (name or "Location") if c not in bad).strip()
|
||||||
|
return (cleaned or "Location")[:31]
|
||||||
|
|
||||||
|
|
||||||
|
def render_excel(report: ProjectNightReport) -> bytes:
|
||||||
|
"""Render the night report as an .xlsx (bytes). One worksheet per location."""
|
||||||
|
import io as _io
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.chart import LineChart, Reference
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
wb.remove(wb.active)
|
||||||
|
|
||||||
|
f_title = Font(name="Arial", bold=True, size=13)
|
||||||
|
f_h = Font(name="Arial", bold=True, size=10)
|
||||||
|
f_d = Font(name="Arial", size=10)
|
||||||
|
f_note = Font(name="Arial", size=9, italic=True, color="888888")
|
||||||
|
center = Alignment(horizontal="center", vertical="center")
|
||||||
|
hdr_fill = PatternFill("solid", fgColor="F2F2F2")
|
||||||
|
thin = Side(style="thin")
|
||||||
|
box = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
|
||||||
|
if not report.locations:
|
||||||
|
ws = wb.create_sheet("No data")
|
||||||
|
ws["A1"] = f"{report.project_name} — no sound locations"
|
||||||
|
ws["A1"].font = f_title
|
||||||
|
|
||||||
|
used_names: set = set()
|
||||||
|
for loc in report.locations:
|
||||||
|
sheet_name = _safe_sheet_name(loc.location_name)
|
||||||
|
n, base = sheet_name, sheet_name
|
||||||
|
i = 2
|
||||||
|
while n in used_names:
|
||||||
|
n = (base[:28] + f"_{i}"); i += 1
|
||||||
|
used_names.add(n)
|
||||||
|
ws = wb.create_sheet(n)
|
||||||
|
metrics = loc.metrics
|
||||||
|
|
||||||
|
ws["A1"] = f"{report.project_name} — Night Report"; ws["A1"].font = f_title
|
||||||
|
ws["A2"] = loc.location_name; ws["A2"].font = f_h
|
||||||
|
ws["A3"] = f"Night of {loc.night_date:%m/%d/%y} · 7PM–7AM"; ws["A3"].font = f_d
|
||||||
|
|
||||||
|
# --- interval table ---
|
||||||
|
hr = 5
|
||||||
|
cols = ["Interval #", "Date", "Time"] + [m.label for m in metrics] + ["Comments"]
|
||||||
|
for ci, label in enumerate(cols, 1):
|
||||||
|
c = ws.cell(row=hr, column=ci, value=label)
|
||||||
|
c.font = f_h; c.alignment = center; c.fill = hdr_fill; c.border = box
|
||||||
|
r = hr + 1
|
||||||
|
for idx, entry in enumerate(loc.interval_series, 1):
|
||||||
|
ws.cell(row=r, column=1, value=idx).border = box
|
||||||
|
dt = entry.get("dt")
|
||||||
|
ws.cell(row=r, column=2, value=(dt.strftime("%m/%d/%y") if dt else "")).border = box
|
||||||
|
ws.cell(row=r, column=3, value=entry.get("time", "")).border = box
|
||||||
|
for mi, m in enumerate(metrics):
|
||||||
|
v = entry.get(m.key)
|
||||||
|
cc = ws.cell(row=r, column=4 + mi, value=(v if isinstance(v, (int, float)) else None))
|
||||||
|
cc.border = box; cc.alignment = center
|
||||||
|
ws.cell(row=r, column=4 + len(metrics), value="").border = box
|
||||||
|
r += 1
|
||||||
|
data_end = max(r - 1, hr + 1)
|
||||||
|
|
||||||
|
ws.column_dimensions["A"].width = 9
|
||||||
|
ws.column_dimensions["B"].width = 10
|
||||||
|
ws.column_dimensions["C"].width = 8
|
||||||
|
for mi in range(len(metrics)):
|
||||||
|
ws.column_dimensions[get_column_letter(4 + mi)].width = 11
|
||||||
|
ws.column_dimensions[get_column_letter(4 + len(metrics))].width = 22
|
||||||
|
|
||||||
|
# --- chart ---
|
||||||
|
if loc.interval_series and metrics:
|
||||||
|
chart = LineChart()
|
||||||
|
chart.title = f"{loc.location_name} — {loc.night_date:%m/%d/%y}"
|
||||||
|
chart.y_axis.title = "dBA"; chart.x_axis.title = "Time"
|
||||||
|
chart.height = 9; chart.width = 18
|
||||||
|
data_ref = Reference(ws, min_col=4, max_col=3 + len(metrics), min_row=hr, max_row=data_end)
|
||||||
|
cats = Reference(ws, min_col=3, min_row=hr + 1, max_row=data_end)
|
||||||
|
chart.add_data(data_ref, titles_from_data=True)
|
||||||
|
chart.set_categories(cats)
|
||||||
|
ws.add_chart(chart, f"{get_column_letter(6 + len(metrics))}5")
|
||||||
|
|
||||||
|
# --- summary: Metric × window (Last / Base / Δ) ---
|
||||||
|
sr = data_end + 3
|
||||||
|
ws.cell(row=sr, column=1, value="Summary — last night vs baseline").font = f_h
|
||||||
|
sr += 1
|
||||||
|
ws.cell(row=sr, column=1, value="Metric").font = f_h
|
||||||
|
win_col = {}
|
||||||
|
col = 2
|
||||||
|
for w in loc.windows:
|
||||||
|
c = ws.cell(row=sr, column=col, value=w.label); c.font = f_h; c.alignment = center
|
||||||
|
ws.merge_cells(start_row=sr, start_column=col, end_row=sr, end_column=col + 2)
|
||||||
|
win_col[w.key] = col
|
||||||
|
col += 3
|
||||||
|
sr += 1
|
||||||
|
for w in loc.windows:
|
||||||
|
b = win_col[w.key]
|
||||||
|
for j, lbl in enumerate(["Last", "Base", "Δ"]):
|
||||||
|
cc = ws.cell(row=sr, column=b + j, value=lbl); cc.font = f_h; cc.alignment = center
|
||||||
|
sr += 1
|
||||||
|
for m in metrics:
|
||||||
|
ws.cell(row=sr, column=1, value=m.label).font = f_d
|
||||||
|
for w in loc.windows:
|
||||||
|
cp = loc.table[w.key][m.key]
|
||||||
|
b = win_col[w.key]
|
||||||
|
ws.cell(row=sr, column=b + 0, value=cp.last_night).alignment = center
|
||||||
|
ws.cell(row=sr, column=b + 1, value=cp.baseline).alignment = center
|
||||||
|
ws.cell(row=sr, column=b + 2, value=cp.delta).alignment = center
|
||||||
|
sr += 1
|
||||||
|
if loc.notes:
|
||||||
|
ws.cell(row=sr + 1, column=1, value="; ".join(loc.notes)).font = f_note
|
||||||
|
|
||||||
|
out = _io.BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
return out.getvalue()
|
||||||
@@ -78,6 +78,9 @@ class SchedulerService:
|
|||||||
# Execute pending actions
|
# Execute pending actions
|
||||||
await self.execute_pending_actions()
|
await self.execute_pending_actions()
|
||||||
|
|
||||||
|
# Run any due nightly sound reports (FTP report pipeline)
|
||||||
|
await self.run_due_reports()
|
||||||
|
|
||||||
# Generate actions from recurring schedules (every hour)
|
# Generate actions from recurring schedules (every hour)
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
if (now - last_generation_check).total_seconds() >= 3600:
|
if (now - last_generation_check).total_seconds() >= 3600:
|
||||||
@@ -633,6 +636,46 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
result["old_session_id"] = active_session.id
|
result["old_session_id"] = active_session.id
|
||||||
|
|
||||||
|
# Step 4b: Ingest the just-finished Auto_#### folder into Terra-View
|
||||||
|
# (clean session + DataFiles via ingest_nrl_zip — filters Lp, parses the
|
||||||
|
# .rnh, dedups). This is what gives the nightly report its data.
|
||||||
|
if action.device_type == "slm" and result["steps"].get("download", {}).get("success"):
|
||||||
|
idx = None
|
||||||
|
try:
|
||||||
|
idx = int((result["steps"]["download"].get("response") or {}).get("index_number"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
idx = None
|
||||||
|
if idx is None:
|
||||||
|
result["steps"]["ingest"] = {"success": False, "error": "no index_number from download"}
|
||||||
|
else:
|
||||||
|
folder_name = f"Auto_{idx:04d}"
|
||||||
|
try:
|
||||||
|
ing = await self._ingest_cycle_folder(db, action.location_id, unit_id, folder_name)
|
||||||
|
result["steps"]["ingest"] = ing
|
||||||
|
db.commit()
|
||||||
|
if ing.get("success"):
|
||||||
|
from backend.models import DataFile
|
||||||
|
sid = ing.get("session_id")
|
||||||
|
# ingest_nrl_zip leaves unit_id None — tie the data session to the
|
||||||
|
# unit that recorded it so it stays linked after we drop the placeholder.
|
||||||
|
if sid:
|
||||||
|
s = db.query(MonitoringSession).filter_by(id=sid).first()
|
||||||
|
if s and not s.unit_id:
|
||||||
|
s.unit_id = unit_id
|
||||||
|
db.commit()
|
||||||
|
# The just-closed "recording" session was only a marker; its data now
|
||||||
|
# lives in the ingested (unit-linked) session. Drop the empty placeholder
|
||||||
|
# and repoint old_session_id at the real row.
|
||||||
|
if active_session and db.query(DataFile).filter_by(session_id=active_session.id).count() == 0:
|
||||||
|
if sid:
|
||||||
|
result["old_session_id"] = sid
|
||||||
|
db.delete(active_session)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"[CYCLE] Ingested {folder_name}: {ing}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CYCLE] Ingest failed for {folder_name}: {e}", exc_info=True)
|
||||||
|
result["steps"]["ingest"] = {"success": False, "error": str(e)}
|
||||||
|
|
||||||
# Step 5: Wait for device to settle before starting new measurement
|
# Step 5: Wait for device to settle before starting new measurement
|
||||||
logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...")
|
logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...")
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
@@ -667,6 +710,33 @@ class SchedulerService:
|
|||||||
|
|
||||||
logger.info(f"[CYCLE] New measurement started, session {new_session.id}")
|
logger.info(f"[CYCLE] New measurement started, session {new_session.id}")
|
||||||
|
|
||||||
|
# Step 6b: Verify the meter actually resumed measuring (fresh DOD).
|
||||||
|
# Polling is still paused here, so query directly. Advisory: a
|
||||||
|
# failure alerts loudly but doesn't fail the cycle (DOD reads can
|
||||||
|
# be transiently flaky); the keepalive poll re-confirms within ~10s.
|
||||||
|
if action.device_type == "slm":
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
live = await self.device_controller.get_live_data(unit_id, action.device_type)
|
||||||
|
state = ((live or {}).get("measurement_state")
|
||||||
|
or ((live or {}).get("data") or {}).get("measurement_state") or "")
|
||||||
|
measuring = str(state).strip().lower() in ("start", "measure", "measuring", "run", "running")
|
||||||
|
result["steps"]["restart_verified"] = measuring
|
||||||
|
if measuring:
|
||||||
|
logger.info(f"[CYCLE] Restart verified — {unit_id} is measuring (state={state}).")
|
||||||
|
else:
|
||||||
|
logger.error(f"[CYCLE] Restart NOT verified for {unit_id} — state={state!r}")
|
||||||
|
try:
|
||||||
|
get_alert_service(db).create_schedule_failed_alert(
|
||||||
|
schedule_id=action.id, action_type="cycle", unit_id=unit_id,
|
||||||
|
error_message=f"Meter did not resume measuring after the cycle (state={state!r}).",
|
||||||
|
project_id=action.project_id, location_id=action.location_id,
|
||||||
|
)
|
||||||
|
except Exception as ae:
|
||||||
|
logger.warning(f"[CYCLE] restart-verify alert failed: {ae}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[CYCLE] Restart verification skipped (DOD read failed): {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[CYCLE] Start failed: {e}")
|
logger.error(f"[CYCLE] Start failed: {e}")
|
||||||
result["steps"]["start"] = {"success": False, "error": str(e)}
|
result["steps"]["start"] = {"success": False, "error": str(e)}
|
||||||
@@ -689,6 +759,37 @@ class SchedulerService:
|
|||||||
logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===")
|
logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def _ingest_cycle_folder(self, db, location_id: str, unit_id: str, folder_name: str) -> dict:
|
||||||
|
"""Fetch a just-finished Auto_#### folder from SLMM (FTP proxy) and ingest
|
||||||
|
it into Terra-View (clean MonitoringSession + DataFiles via ingest_nrl_zip).
|
||||||
|
|
||||||
|
Returns the ingest result dict, or {"success": False, "error": ...}.
|
||||||
|
Used by _execute_cycle Step 4b.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
from backend.routers.project_locations import ingest_nrl_zip, IngestError
|
||||||
|
|
||||||
|
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
remote_path = f"/NL-43/{folder_name}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=600.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{slmm_base}/api/nl43/{unit_id}/ftp/download-folder",
|
||||||
|
json={"remote_path": remote_path},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"download-folder request failed: {e}"}
|
||||||
|
|
||||||
|
if not resp.is_success or len(resp.content) <= 22: # 22 bytes = empty-zip
|
||||||
|
return {"success": False, "error": f"empty/failed ZIP from SLMM (status {resp.status_code})"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = ingest_nrl_zip(location_id, resp.content, db, source="ftp_cycle", dedupe=True)
|
||||||
|
return {"success": True, **res}
|
||||||
|
except IngestError as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Recurring Schedule Generation
|
# Recurring Schedule Generation
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -782,6 +883,92 @@ class SchedulerService:
|
|||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Nightly Sound Report (FTP report pipeline)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def run_due_reports(self):
|
||||||
|
"""Run any project nightly sound reports that are due.
|
||||||
|
|
||||||
|
For each enabled SoundReportConfig: if local time is past report_time
|
||||||
|
and we haven't already reported last night, build the report (writes a
|
||||||
|
file always; emails if SMTP is configured, else dry-run) and stamp
|
||||||
|
last_run_date. Idempotent across restarts via last_run_date.
|
||||||
|
"""
|
||||||
|
from backend.models import SoundReportConfig
|
||||||
|
from backend.utils.timezone import utc_to_local
|
||||||
|
|
||||||
|
# Decide what's due (cheap, on the loop); run each OFF the event loop.
|
||||||
|
due_jobs = []
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
configs = db.query(SoundReportConfig).filter_by(enabled=True).all()
|
||||||
|
if not configs:
|
||||||
|
return
|
||||||
|
local_now = utc_to_local(datetime.utcnow())
|
||||||
|
night_date = local_now.date() - timedelta(days=1) # last night's evening date
|
||||||
|
for cfg in configs:
|
||||||
|
try:
|
||||||
|
hh, mm = (int(x) for x in cfg.report_time.split(":"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
hh, mm = 8, 0
|
||||||
|
if (local_now.hour, local_now.minute) < (hh, mm):
|
||||||
|
continue
|
||||||
|
if cfg.last_run_date == night_date:
|
||||||
|
continue
|
||||||
|
due_jobs.append({
|
||||||
|
"project_id": cfg.project_id,
|
||||||
|
"metric_keys": [m.strip() for m in (cfg.metric_keys or "").split(",") if m.strip()] or None,
|
||||||
|
"recipients": [r.strip() for r in (cfg.recipients or "").split(",") if r.strip()] or None,
|
||||||
|
"baseline_mode": cfg.baseline_mode,
|
||||||
|
"baseline_start": cfg.baseline_start,
|
||||||
|
"baseline_end": cfg.baseline_end,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# run_nightly_report is synchronous (blocking file I/O + smtplib up to the
|
||||||
|
# SMTP timeout). Run it in a worker thread so it never stalls the scheduler
|
||||||
|
# loop (which also drives time-sensitive device cycles).
|
||||||
|
for job in due_jobs:
|
||||||
|
try:
|
||||||
|
logger.info(f"[REPORT] Running nightly report for project {job['project_id']} (night {night_date})")
|
||||||
|
result = await asyncio.to_thread(self._run_one_report, night_date, job)
|
||||||
|
email = (result or {}).get("email", {})
|
||||||
|
logger.info(
|
||||||
|
f"[REPORT] project {job['project_id']}: {(result or {}).get('location_count')} location(s); "
|
||||||
|
f"email={'sent' if email.get('sent') else ('dry-run' if email.get('dry_run') else (email.get('error') or 'skipped'))}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[REPORT] Failed nightly report for project {job['project_id']}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _run_one_report(self, night_date, job) -> Dict[str, Any]:
|
||||||
|
"""Sync worker: build/send one project's report and stamp last_run_date.
|
||||||
|
Uses its own DB session (runs in a thread, off the event loop)."""
|
||||||
|
from backend.models import SoundReportConfig
|
||||||
|
from backend.services.report_orchestrator import run_nightly_report
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = run_nightly_report(
|
||||||
|
db, job["project_id"], night_date,
|
||||||
|
metric_keys=job["metric_keys"],
|
||||||
|
baseline_mode=job["baseline_mode"],
|
||||||
|
baseline_start=job["baseline_start"],
|
||||||
|
baseline_end=job["baseline_end"],
|
||||||
|
recipients=job["recipients"],
|
||||||
|
)
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=job["project_id"]).first()
|
||||||
|
if cfg:
|
||||||
|
cfg.last_run_date = night_date
|
||||||
|
db.commit()
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Manual Execution (for testing/debugging)
|
# Manual Execution (for testing/debugging)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -321,6 +321,11 @@ async def events_for_unit(
|
|||||||
"assignment_id": a.id,
|
"assignment_id": a.id,
|
||||||
"location_id": a.location_id,
|
"location_id": a.location_id,
|
||||||
"location_name": loc.name if loc else None,
|
"location_name": loc.name if loc else None,
|
||||||
|
# Soft-removal indicator so the UI can render a "(removed)"
|
||||||
|
# badge next to historical attributions whose location is no
|
||||||
|
# longer actively monitored.
|
||||||
|
"location_removed_at": (loc.removed_at.isoformat()
|
||||||
|
if loc and loc.removed_at else None),
|
||||||
"project_id": a.project_id,
|
"project_id": a.project_id,
|
||||||
"project_name": proj.name if proj else None,
|
"project_name": proj.name if proj else None,
|
||||||
"assigned_at": _iso_utc(a.assigned_at),
|
"assigned_at": _iso_utc(a.assigned_at),
|
||||||
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
|
|||||||
"event_count": ec,
|
"event_count": ec,
|
||||||
"peak_pvs": ev_peak,
|
"peak_pvs": ev_peak,
|
||||||
"last_event": ev_last,
|
"last_event": ev_last,
|
||||||
|
# Soft-removal state — UI can show a "(removed)" badge in the
|
||||||
|
# per-location list so operators see at a glance that a row's
|
||||||
|
# numbers are historical-only.
|
||||||
|
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.database import get_db_session
|
from backend.database import get_db_session
|
||||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
from backend.services.unit_location import bulk_active_locations
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,6 +138,10 @@ def emit_status_snapshot():
|
|||||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
|
|
||||||
|
# Active-assignment location lookup for all roster units (direct only;
|
||||||
|
# modems inherit from their paired device below in the derive loop).
|
||||||
|
active_locs = bulk_active_locations(db, list(roster.values()))
|
||||||
|
|
||||||
# SFM event-forwards are now the primary "last seen" signal for
|
# SFM event-forwards are now the primary "last seen" signal for
|
||||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||||
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
||||||
@@ -225,10 +230,13 @@ def emit_status_snapshot():
|
|||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
# Location for mapping
|
# Location for mapping — sourced from active UnitAssignment
|
||||||
"location": r.location or "",
|
# → MonitoringLocation. Empty for benched / unassigned.
|
||||||
"address": r.address or "",
|
"address": (active_locs.get(unit_id) or {}).get("address") or "",
|
||||||
"coordinates": r.coordinates or "",
|
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "",
|
||||||
|
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
|
||||||
|
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
|
||||||
|
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Add unexpected emitter-only units ---
|
# --- Add unexpected emitter-only units ---
|
||||||
@@ -267,10 +275,12 @@ def emit_status_snapshot():
|
|||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
# Location fields
|
# Location fields — unknown units have no assignment
|
||||||
"location": "",
|
|
||||||
"address": "",
|
"address": "",
|
||||||
"coordinates": "",
|
"coordinates": "",
|
||||||
|
"location_name": "",
|
||||||
|
"project_id": "",
|
||||||
|
"location_id": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Derive modem status from paired devices ---
|
# --- Derive modem status from paired devices ---
|
||||||
@@ -301,6 +311,11 @@ def emit_status_snapshot():
|
|||||||
unit_data["last"] = paired_unit.get("last")
|
unit_data["last"] = paired_unit.get("last")
|
||||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||||
unit_data["derived_from"] = paired_unit_id
|
unit_data["derived_from"] = paired_unit_id
|
||||||
|
# Inherit deployment location too — modems don't carry
|
||||||
|
# their own UnitAssignment.
|
||||||
|
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
|
||||||
|
if not unit_data.get(k):
|
||||||
|
unit_data[k] = paired_unit.get(k, "")
|
||||||
|
|
||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Active-assignment location resolution for roster units.
|
||||||
|
|
||||||
|
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
|
||||||
|
The current source of truth for "where is this unit deployed right now" is the
|
||||||
|
active `UnitAssignment` (assigned_until IS NULL) pointing at a
|
||||||
|
`MonitoringLocation`, which carries the canonical address/coordinates/name.
|
||||||
|
|
||||||
|
Modems don't get their own `UnitAssignment` — they're paired with a
|
||||||
|
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
|
||||||
|
location of its paired device's active assignment.
|
||||||
|
|
||||||
|
Returned dict shape (or None if no active assignment resolvable):
|
||||||
|
{
|
||||||
|
"location_id": "uuid",
|
||||||
|
"project_id": "uuid",
|
||||||
|
"name": "NRL-001",
|
||||||
|
"address": "123 Main St" | None,
|
||||||
|
"coordinates": "34.0522,-118.2437" | None,
|
||||||
|
"via_paired_unit_id": "BE1234" | None, # set only for modems
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
|
||||||
|
return {
|
||||||
|
"location_id": loc.id,
|
||||||
|
"project_id": loc.project_id,
|
||||||
|
"name": loc.name,
|
||||||
|
"address": loc.address or None,
|
||||||
|
"coordinates": loc.coordinates or None,
|
||||||
|
"via_paired_unit_id": via_paired_unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
|
||||||
|
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
|
||||||
|
row = (
|
||||||
|
db.query(MonitoringLocation)
|
||||||
|
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
|
||||||
|
.filter(
|
||||||
|
UnitAssignment.unit_id == unit_id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
)
|
||||||
|
.order_by(UnitAssignment.assigned_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Resolve the active deployment location for a unit.
|
||||||
|
|
||||||
|
Seismographs / SLMs: their own active UnitAssignment.
|
||||||
|
Modems: follow `deployed_with_unit_id` to the paired device's active
|
||||||
|
assignment (modems don't carry their own assignment).
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if unit is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if (unit.device_type or "seismograph") == "modem":
|
||||||
|
paired_id = unit.deployed_with_unit_id
|
||||||
|
if not paired_id:
|
||||||
|
return None
|
||||||
|
loc = _active_location_for_unit_id(db, paired_id)
|
||||||
|
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
|
||||||
|
|
||||||
|
loc = _active_location_for_unit_id(db, unit_id)
|
||||||
|
return _serialize(loc) if loc else None
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Resolve active locations for many units in two queries. Use this from
|
||||||
|
snapshot-style loops to avoid N+1 lookups.
|
||||||
|
|
||||||
|
Returns {unit_id: <serialized location dict>} — only populated for units
|
||||||
|
that resolve to an active assignment. Modems are resolved by walking
|
||||||
|
`deployed_with_unit_id` to the paired device's entry in the same map.
|
||||||
|
"""
|
||||||
|
if not units:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
direct_unit_ids = [
|
||||||
|
u.id for u in units
|
||||||
|
if (u.device_type or "seismograph") != "modem"
|
||||||
|
]
|
||||||
|
|
||||||
|
direct: dict[str, MonitoringLocation] = {}
|
||||||
|
if direct_unit_ids:
|
||||||
|
rows = (
|
||||||
|
db.query(UnitAssignment.unit_id, MonitoringLocation)
|
||||||
|
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
|
||||||
|
.filter(
|
||||||
|
UnitAssignment.unit_id.in_(direct_unit_ids),
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
)
|
||||||
|
.order_by(UnitAssignment.assigned_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# First row wins per unit_id (most recent assigned_at).
|
||||||
|
for unit_id, loc in rows:
|
||||||
|
direct.setdefault(unit_id, loc)
|
||||||
|
|
||||||
|
out: dict[str, dict] = {
|
||||||
|
uid: _serialize(loc) for uid, loc in direct.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modems inherit from paired device.
|
||||||
|
for u in units:
|
||||||
|
if (u.device_type or "seismograph") != "modem":
|
||||||
|
continue
|
||||||
|
paired_id = u.deployed_with_unit_id
|
||||||
|
if paired_id and paired_id in direct:
|
||||||
|
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
|
||||||
|
|
||||||
|
return out
|
||||||
+478
-11
@@ -28,6 +28,27 @@
|
|||||||
(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 = 'psi'; // 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, '&')
|
||||||
@@ -109,7 +130,10 @@
|
|||||||
function _renderUserNotes(s) {
|
function _renderUserNotes(s) {
|
||||||
// The "user notes" metadata the operator typed into the BW device.
|
// The "user notes" metadata the operator typed into the BW device.
|
||||||
// These are the strings the future metadata-driven parser will use.
|
// These are the strings the future metadata-driven parser will use.
|
||||||
const p = s.user_notes || {};
|
// NOTE: SFM's sidecar JSON still names this block `project_info` —
|
||||||
|
// we render it as "User Notes" (the actual BW term) but read the
|
||||||
|
// field by its SFM-API name. Rename in SFM is a future cleanup.
|
||||||
|
const p = s.project_info || {};
|
||||||
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '—')}</span></div>
|
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '—')}</span></div>
|
||||||
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
|
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
|
||||||
@@ -221,32 +245,370 @@
|
|||||||
</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 === 'dBL' ? 'dBL' : 'psi';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Network error → silent fall back to default 'psi'.
|
||||||
|
}
|
||||||
|
_micUnitPrefLoaded = true;
|
||||||
|
return _micUnitPref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _psiToDbl(psi) {
|
||||||
|
if (psi == null || !(psi > 0)) return null;
|
||||||
|
return 20 * Math.log10(psi / DBL_REF);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rectifying psi→dBL converter for per-sample values — see comments in
|
||||||
|
// sfm_webapp.html:2592-2607 for the floor rationale.
|
||||||
|
function _psiToDblForChart(psi) {
|
||||||
|
if (psi == null) return MIC_DBL_FLOOR;
|
||||||
|
const a = Math.abs(psi);
|
||||||
|
if (a === 0) return MIC_DBL_FLOOR;
|
||||||
|
const dbl = 20 * Math.log10(a / DBL_REF);
|
||||||
|
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive decimal formatter — sensible precision in the normal range,
|
||||||
|
// scientific notation only at the extremes.
|
||||||
|
function _fmtPeak(v, unit) {
|
||||||
|
if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
|
||||||
|
if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
|
||||||
|
if (v === 0) return '0' + (unit ? ' ' + unit : '');
|
||||||
|
const a = Math.abs(v);
|
||||||
|
const u = unit ? ' ' + unit : '';
|
||||||
|
if (a >= 0.0001 && a < 10000) {
|
||||||
|
const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
|
||||||
|
return v.toFixed(d) + u;
|
||||||
|
}
|
||||||
|
return v.toExponential(2) + u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _destroyCharts() {
|
||||||
|
Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
|
||||||
|
_charts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true when Tailwind dark mode is active (the `dark` class is
|
||||||
|
// toggled on <html> by Terra-View's theme handler). Drives chart grid
|
||||||
|
// + tick colors so they have contrast on both backgrounds.
|
||||||
|
function _isDark() {
|
||||||
|
return document.documentElement.classList.contains('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderWaveformInto(containerId, data, micUnit) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
_destroyCharts();
|
||||||
|
|
||||||
|
const channels = data.channels || {};
|
||||||
|
const ta = data.time_axis || {};
|
||||||
|
const sr = ta.sample_rate || 1024;
|
||||||
|
const dtMs = ta.dt_ms || (1000.0 / sr);
|
||||||
|
const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
|
||||||
|
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
|
||||||
|
|
||||||
|
const withData = _CHANNEL_ORDER.filter(ch =>
|
||||||
|
channels[ch] && (channels[ch].values || []).length > 0
|
||||||
|
);
|
||||||
|
const lastCh = withData[withData.length - 1];
|
||||||
|
|
||||||
|
// Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
|
||||||
|
// light is white. Grids + ticks need contrast on both.
|
||||||
|
const dark = _isDark();
|
||||||
|
const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||||
|
const tickColor = dark ? '#94a3b8' : '#64748b';
|
||||||
|
|
||||||
|
if (withData.length === 0) {
|
||||||
|
container.innerHTML = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
|
||||||
|
No waveform samples decoded — codec walker returned 0 valid blocks for this event.
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ch of _CHANNEL_ORDER) {
|
||||||
|
const chData = channels[ch];
|
||||||
|
if (!chData) continue;
|
||||||
|
let values = chData.values || [];
|
||||||
|
let chUnit = chData.unit || '';
|
||||||
|
let chPeak = chData.peak;
|
||||||
|
|
||||||
|
// Mic: convert psi → dBL when the user pref is dBL (default).
|
||||||
|
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
|
||||||
|
values = values.map(_psiToDblForChart);
|
||||||
|
chPeak = _psiToDbl(chPeak);
|
||||||
|
chUnit = 'dB(L)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
|
||||||
|
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
|
||||||
|
lbl.style.color = _CHANNEL_COLORS[ch];
|
||||||
|
const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
|
||||||
|
lbl.innerHTML = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
|
||||||
|
wrap.appendChild(lbl);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
const e = document.createElement('div');
|
||||||
|
e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
|
||||||
|
e.textContent = 'no samples decoded';
|
||||||
|
wrap.appendChild(e);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasWrap = document.createElement('div');
|
||||||
|
canvasWrap.className = 'relative';
|
||||||
|
canvasWrap.style.height = '100px';
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvasWrap.appendChild(canvas);
|
||||||
|
wrap.appendChild(canvasWrap);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
|
||||||
|
// X-axis: waveforms use ms-relative-to-trigger; histograms use
|
||||||
|
// the BW-reported interval timestamps (HH:MM:SS) when the server
|
||||||
|
// aggregated to BW intervals, else interval index.
|
||||||
|
let times;
|
||||||
|
if (isHistogram) {
|
||||||
|
const intervalTimes = ta.interval_times || [];
|
||||||
|
times = (intervalTimes.length === values.length)
|
||||||
|
? intervalTimes
|
||||||
|
: values.map((_, i) => i + 1);
|
||||||
|
} else {
|
||||||
|
times = values.map((_, i) => t0Ms + i * dtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downsample for rendering when very long.
|
||||||
|
const MAX = 3000;
|
||||||
|
let rT = times, rV = values;
|
||||||
|
if (values.length > MAX) {
|
||||||
|
const step = Math.ceil(values.length / MAX);
|
||||||
|
rT = times.filter((_, i) => i % step === 0);
|
||||||
|
rV = values.filter((_, i) => i % step === 0);
|
||||||
|
}
|
||||||
|
const showX = (ch === lastCh);
|
||||||
|
|
||||||
|
const xAxisLabel = isHistogram ? '' : ' ms';
|
||||||
|
const fmtTick = i => {
|
||||||
|
const v = rT[i];
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
|
||||||
|
return s + xAxisLabel;
|
||||||
|
}
|
||||||
|
return String(v) + xAxisLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
|
||||||
|
let yBounds = {};
|
||||||
|
const isGeo = ch !== 'MicL';
|
||||||
|
if (isGeo && !isHistogram) {
|
||||||
|
let absMax = 0;
|
||||||
|
for (const v of values) {
|
||||||
|
const a = Math.abs(v);
|
||||||
|
if (a > absMax) absMax = a;
|
||||||
|
}
|
||||||
|
const padded = (absMax || 1) * 1.10;
|
||||||
|
yBounds = { min: -padded, max: padded };
|
||||||
|
} else if (isGeo && isHistogram) {
|
||||||
|
const HIST_GEO_MIN_INS = 0.05;
|
||||||
|
let peak = 0;
|
||||||
|
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
|
||||||
|
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
|
||||||
|
} else if (ch === 'MicL' && micUnit === 'dBL') {
|
||||||
|
const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
|
||||||
|
? chPeak + 5 : 100;
|
||||||
|
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
|
||||||
|
} else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
|
||||||
|
const HIST_MIC_MIN_PSI = 0.001;
|
||||||
|
let peak = 0;
|
||||||
|
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
|
||||||
|
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
|
||||||
|
}
|
||||||
|
|
||||||
|
_charts[ch] = new Chart(canvas, {
|
||||||
|
type: isHistogram ? 'bar' : 'line',
|
||||||
|
data: {
|
||||||
|
labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
|
||||||
|
datasets: isHistogram ? [{
|
||||||
|
data: rV,
|
||||||
|
backgroundColor: _CHANNEL_COLORS[ch],
|
||||||
|
borderWidth: 0,
|
||||||
|
barPercentage: 1.0,
|
||||||
|
categoryPercentage: 1.0,
|
||||||
|
}] : [{
|
||||||
|
data: rV,
|
||||||
|
borderColor: _CHANNEL_COLORS[ch],
|
||||||
|
borderWidth: 1,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: false, responsive: true, maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index', intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: items => isHistogram
|
||||||
|
? `interval ${items[0].label}`
|
||||||
|
: `t = ${items[0].label} ms`,
|
||||||
|
label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'category', display: showX,
|
||||||
|
ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
|
||||||
|
grid: { color: gridColor, drawTicks: showX },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
...yBounds,
|
||||||
|
ticks: { color: tickColor, maxTicksLimit: 4 },
|
||||||
|
grid: { color: gridColor },
|
||||||
|
title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: isHistogram ? [] : [{
|
||||||
|
id: 'overlays',
|
||||||
|
afterDraw(chart) {
|
||||||
|
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
|
||||||
|
const zi = rT.findIndex(t => parseFloat(t) >= 0);
|
||||||
|
if (zi >= 0) {
|
||||||
|
const px = x.getPixelForValue(zi);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
|
||||||
|
ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
|
||||||
|
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
const zy = y.getPixelForValue(0);
|
||||||
|
if (zy >= y.top && zy <= y.bottom) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
|
||||||
|
ctx.setLineDash([2, 2]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = tickColor; ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('0.0', x.right + 6, zy);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _renderFileInfo(s, eventId) {
|
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-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
||||||
Download Blastware file
|
Blastware binary
|
||||||
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
|
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span>
|
||||||
</a>
|
</a>
|
||||||
` : `
|
` : ''}
|
||||||
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
|
${txtFilename ? `
|
||||||
|
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Blastware file unavailable
|
Original .TXT report
|
||||||
</span>
|
</a>
|
||||||
`}
|
` : ''}
|
||||||
<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">
|
||||||
@@ -264,6 +626,11 @@
|
|||||||
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>
|
||||||
@@ -280,7 +647,9 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
||||||
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
||||||
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span></div>
|
<div title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that).">
|
||||||
|
<span class="text-gray-500">Time received</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span>
|
||||||
|
</div>
|
||||||
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -342,6 +711,10 @@
|
|||||||
${_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)}
|
||||||
@@ -355,14 +728,43 @@
|
|||||||
${_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 () {
|
||||||
@@ -373,6 +775,71 @@
|
|||||||
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
-2
@@ -1,18 +1,27 @@
|
|||||||
/* Service Worker for Seismo Fleet Manager PWA */
|
/* Service Worker for Seismo Fleet Manager PWA */
|
||||||
/* Network-first strategy with cache fallback for real-time data */
|
/* Network-first strategy with cache fallback for real-time data */
|
||||||
|
|
||||||
const CACHE_VERSION = 'v1';
|
// IMPORTANT: bump this on every release that touches a precached or
|
||||||
|
// runtime-cached static asset (event-modal.js, mobile.js, style.css,
|
||||||
|
// templates served at /, etc.). The activate handler deletes any cache
|
||||||
|
// not matching CACHE_VERSION, so old SW caches get evicted and mobile
|
||||||
|
// PWA users actually receive the new bundles instead of being stuck on
|
||||||
|
// the pre-bump version. Convention: keep it in sync with the Terra-View
|
||||||
|
// version string in backend/main.py.
|
||||||
|
const CACHE_VERSION = 'v0.13.2';
|
||||||
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||||
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||||
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||||
|
|
||||||
// Files to precache (critical app shell)
|
// Files to precache (critical app shell). event-modal.js is included
|
||||||
|
// so its cache lifecycle is tied to the SW version bump explicitly.
|
||||||
const STATIC_FILES = [
|
const STATIC_FILES = [
|
||||||
'/',
|
'/',
|
||||||
'/static/style.css',
|
'/static/style.css',
|
||||||
'/static/mobile.css',
|
'/static/mobile.css',
|
||||||
'/static/mobile.js',
|
'/static/mobile.js',
|
||||||
'/static/offline-db.js',
|
'/static/offline-db.js',
|
||||||
|
'/static/event-modal.js',
|
||||||
'/static/manifest.json',
|
'/static/manifest.json',
|
||||||
'https://cdn.tailwindcss.com',
|
'https://cdn.tailwindcss.com',
|
||||||
'https://unpkg.com/htmx.org@1.9.10',
|
'https://unpkg.com/htmx.org@1.9.10',
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
terra-view:
|
web-app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
@@ -11,6 +11,10 @@ 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
|
||||||
@@ -56,9 +60,18 @@ 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"]
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Client Portal — Design & Build Plan
|
||||||
|
|
||||||
|
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
|
||||||
|
|
||||||
|
A client-facing, **read-only**, **scoped** view into a client's own monitoring
|
||||||
|
data. The first internet-facing-with-real-clients surface in the system. Built
|
||||||
|
*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM
|
||||||
|
reads and Terra-View's report generation — Terra-View stays the UI/business layer;
|
||||||
|
SLMM stays the device layer.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Read-only.** No device control (start/stop/config), no roster editing, no
|
||||||
|
internal pages. A client can look, never touch.
|
||||||
|
2. **Strictly scoped.** A client only ever sees data for *their* projects. Every
|
||||||
|
portal endpoint verifies ownership server-side — never trust a `unit_id` /
|
||||||
|
`location_id` from the request.
|
||||||
|
3. **Cache-first, no device contention.** Portal live data comes from SLMM's
|
||||||
|
cache (the same cached `/status` + `/history` the internal dashboard uses).
|
||||||
|
No device-hitting calls from the portal — a client can't make us hammer the
|
||||||
|
NL-43. Freshness depends on **keepalive being on** for the client's units.
|
||||||
|
4. **Auth is a swappable gate.** Every route depends on one resolver,
|
||||||
|
`get_current_client()`. M1–M3 ride on an interim signed "magic URL"; M4
|
||||||
|
replaces the resolver's backing without touching routes or templates.
|
||||||
|
|
||||||
|
## The data chain (how a client maps to live data)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client.id
|
||||||
|
└─ Project (client_id == Client.id, status != deleted)
|
||||||
|
└─ MonitoringLocation (project_id, location_type == "sound", removed_at IS NULL)
|
||||||
|
└─ UnitAssignment (location_id, status == "active", device_type == "slm",
|
||||||
|
assigned_until IS NULL or future)
|
||||||
|
└─ unit_id == RosterUnit.id == SLMM unit_id
|
||||||
|
└─ SLMM cached /status + /history (read-only)
|
||||||
|
```
|
||||||
|
|
||||||
|
So the portal shows a client their **locations**, each surfacing the live sound
|
||||||
|
level from whatever SLM is currently assigned there.
|
||||||
|
|
||||||
|
## Data model (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Client(Base): # the customer org
|
||||||
|
id, name, slug (unique, URL-safe), contact_email (nullable, for M4),
|
||||||
|
active (bool), created_at
|
||||||
|
|
||||||
|
class ClientAccessToken(Base): # the interim "magic URL" gate
|
||||||
|
id, client_id, token_hash (sha256 — raw shown once on creation),
|
||||||
|
label, created_at, last_used_at, revoked_at (nullable)
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a migration adding **`Project.client_id`** (nullable FK → `clients.id`).
|
||||||
|
The existing free-text `Project.client_name` stays for display/back-compat;
|
||||||
|
`client_id` is the authoritative link.
|
||||||
|
|
||||||
|
## Auth — the swappable gate
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_current_client(request, db) -> Client: # every /portal route depends on this
|
||||||
|
# M1–M3: read signed `portal_client` cookie -> load Client
|
||||||
|
# M4: same signature, backed by real sessions (magic-link / password)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interim "magic URL" flow (M1–M3):**
|
||||||
|
- Operator creates a `Client` + an access token → gets a one-time-display URL:
|
||||||
|
`https://…/portal/enter/{token}`.
|
||||||
|
- Client clicks it → token is hashed, looked up (must be un-revoked) →
|
||||||
|
sets a **signed session cookie** (`portal_client`, HMAC via a new `SECRET_KEY`
|
||||||
|
env) → redirects to `/portal`. `last_used_at` updated.
|
||||||
|
- `get_current_client` reads + verifies the cookie thereafter. No valid cookie →
|
||||||
|
"link invalid / expired" page.
|
||||||
|
- Revoke = set `revoked_at`; the link (and any cookie minted from it) stops working.
|
||||||
|
|
||||||
|
Unguessable + revocable + per-person, no email infra or passwords yet — and M4
|
||||||
|
slots in behind the same `get_current_client` with zero route/template churn.
|
||||||
|
|
||||||
|
## Routes (`/portal/*`)
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `GET /portal/enter/{token}` | validate token → set cookie → redirect to `/portal` |
|
||||||
|
| `GET /portal` | client's locations overview (status tiles + map) |
|
||||||
|
| `GET /portal/location/{id}` | read-only live panel for that location's SLM |
|
||||||
|
| `GET /portal/api/location/{id}/live` | **scoped** cached `/status` for the location's unit |
|
||||||
|
| `GET /portal/api/location/{id}/history` | **scoped** cached trail for the chart |
|
||||||
|
| `GET /portal/logout` | clear cookie |
|
||||||
|
|
||||||
|
**Scoping helper** (used by every data route):
|
||||||
|
`resolve_client_location(client, location_id, db) -> (location, unit_id)` — raises
|
||||||
|
403 if the location isn't in one of the client's projects. The portal never calls
|
||||||
|
the open `/api/slmm/{unit}/*` endpoints with a client-supplied id.
|
||||||
|
|
||||||
|
## Templates (`templates/portal/`)
|
||||||
|
|
||||||
|
- `portal/base.html` — minimal client-branded shell (no internal sidebar/nav).
|
||||||
|
- `portal/overview.html` — location tiles (live cards mini) + a locations map.
|
||||||
|
- `portal/location.html` — the read-only live panel: cards (Lp/Leq/Lmax/L1/L10),
|
||||||
|
L1/L10 chart, measuring + freshness badge. Reuses the cache-populate JS from the
|
||||||
|
internal panel, **stripped** of start/stop, config, and the device-hitting
|
||||||
|
refresh (cache + 15s auto-poll only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### M1 — Live view only *(current)*
|
||||||
|
Interim magic-URL gate; a client sees their locations and per-location read-only
|
||||||
|
live data, all from cache.
|
||||||
|
- [ ] `Client` + `ClientAccessToken` models; `Project.client_id` migration.
|
||||||
|
- [ ] `SECRET_KEY` env + signed-cookie session helper.
|
||||||
|
- [ ] `get_current_client` dependency + `/portal/enter/{token}` + logout.
|
||||||
|
- [ ] Scoping helper `resolve_client_location`.
|
||||||
|
- [ ] `/portal` overview + `/portal/location/{id}` (read-only live panel).
|
||||||
|
- [ ] Scoped `/portal/api/location/{id}/live` + `/history`.
|
||||||
|
- [ ] Portal templates (base, overview, location).
|
||||||
|
- [ ] Minimal admin: create client + mint/revoke access link (small `/admin`
|
||||||
|
page or a script for now).
|
||||||
|
|
||||||
|
### M2 — Dashboard + alerts
|
||||||
|
- Richer client dashboard (multi-location at-a-glance, status rollup).
|
||||||
|
- **Live project map** — upgrade the overview's basic location pins into a real
|
||||||
|
project map: pins colored by measuring/level, popups showing each location's
|
||||||
|
current reading, centered/zoomed to the project. (M1 ships the plain pin map;
|
||||||
|
this makes it a live status map.)
|
||||||
|
- Surface each location's **threshold-alert status** (read-only) + an event/inbox
|
||||||
|
view. Leans on the SLMM alert engine + dispatch.
|
||||||
|
|
||||||
|
### Notes carried from M1
|
||||||
|
- Tile headline metric is **Leq** (energy-average, the sound-monitoring compliance
|
||||||
|
metric) — chosen over the twitchy instantaneous Lp. If clients ever want a
|
||||||
|
different headline (e.g. Lmax for peaks), make it a per-deployment setting.
|
||||||
|
|
||||||
|
### M3 — Reports
|
||||||
|
- Client-facing list + download of the daily baseline-comparison reports.
|
||||||
|
- Depends on the FTP report pipeline (`feat/ftp-report-pipeline`) landing and
|
||||||
|
being wired into the portal's scoped routes.
|
||||||
|
|
||||||
|
### M4 — Full auth system
|
||||||
|
- Replace the interim token behind `get_current_client` with a real auth design:
|
||||||
|
magic-link (passwordless email) and/or accounts, proper sessions, password
|
||||||
|
reset, and likely auth for the *internal* app too. Reverse-proxy + TLS posture.
|
||||||
|
|
||||||
|
## Going to prod (M1)
|
||||||
|
|
||||||
|
1. **Run the migration on the prod DB** — `migrate_add_client_portal.py` adds
|
||||||
|
`projects.client_id` (the new tables auto-create via `create_all`). Skipping it
|
||||||
|
500s anything that touches `Project.client_id`. This is the silent killer.
|
||||||
|
```bash
|
||||||
|
docker compose exec web-app python3 backend/migrate_add_client_portal.py
|
||||||
|
```
|
||||||
|
2. **Set a real `SECRET_KEY`** in the prod env (compose). The portal signs session
|
||||||
|
cookies with it; the insecure dev default (it logs a warning at boot) is
|
||||||
|
forgeable. Non-negotiable for an internet-facing portal.
|
||||||
|
3. **SLMM_BASE_URL** — prod base compose already points at `:8100` (correct; the
|
||||||
|
`:9100` mismatch is a dev-only override quirk). For full live data (L1/L10 +
|
||||||
|
chart backfill) prod SLMM must be on the `dev` build with its migrations
|
||||||
|
(`migrate_add_ln_percentiles`, `migrate_add_monitor_enabled`) and **keepalive on**
|
||||||
|
for the client's units — otherwise the portal degrades gracefully (cards show
|
||||||
|
`--`, chart empty), it just isn't fully populated.
|
||||||
|
4. **Seed real clients** with the CLI (`backend/portal_admin.py`): `create-client`
|
||||||
|
→ `link-project` (a real sound project with an active SLM assignment) →
|
||||||
|
`mint-link` → send the client the printed URL (shown once).
|
||||||
|
5. **Exposure** — portal routes are auth-gated, but port 8001 still serves the
|
||||||
|
whole *internal* app with no auth. Before real clients are on it, the portal
|
||||||
|
should sit behind the reverse proxy with only `/portal/*` exposed (or the app
|
||||||
|
restricted). This is the point where the parked reverse-proxy/TLS work becomes
|
||||||
|
load-bearing.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Portal is auth-gated from day one (even the interim gate) — never wide-open like
|
||||||
|
the internal app.
|
||||||
|
- All scoping enforced server-side; client-supplied ids are always re-checked.
|
||||||
|
- `SECRET_KEY` must be a real secret in prod (env, not committed).
|
||||||
|
- Cookies: `HttpOnly`, `SameSite=Lax`, `Secure` once behind TLS.
|
||||||
|
- Tokens stored hashed; raw shown once. Revocation is immediate.
|
||||||
|
|
||||||
|
## Security hardening backlog ("Fest 2026")
|
||||||
|
|
||||||
|
The to-do for the dedicated hardening pass, roughly highest-impact first. Until
|
||||||
|
then the portal runs on security-by-obscurity (open port + interim links) — fine
|
||||||
|
for a not-in-use demo, not for real clients.
|
||||||
|
|
||||||
|
**Exposure (the big one):** port 8001 serves the *entire operator app* (roster,
|
||||||
|
projects, `/admin/*`, device config, the SLMM proxy) with **zero auth**, so an
|
||||||
|
open port exposes far more than the read-only portal.
|
||||||
|
- [ ] Reverse proxy (NPM/Caddy/Nginx) in front, exposing **only `/portal/*`** to
|
||||||
|
the internet; keep the operator app reachable on the LAN only.
|
||||||
|
- [ ] TLS everywhere (Let's Encrypt). Then set portal cookies `Secure`.
|
||||||
|
- [ ] Don't port-forward the raw app; if a quick gate is wanted before M4, an
|
||||||
|
auth proxy (Authelia / Authentik) can front the portal without writing auth.
|
||||||
|
|
||||||
|
**Config musts:**
|
||||||
|
- [ ] Set a real `SECRET_KEY` env (signs session cookies; default is public).
|
||||||
|
- [ ] `PORTAL_OPEN_LINKS=false` in any internet-facing env (it defaults off now).
|
||||||
|
|
||||||
|
**M4 — real auth** (replaces the interim token behind `get_current_client`):
|
||||||
|
- [ ] Magic-link email and/or accounts; proper sessions + password reset.
|
||||||
|
- [ ] Authenticate the **operator** app too (it currently has none).
|
||||||
|
- [ ] Gate the operator-only endpoints that are presently unauthenticated:
|
||||||
|
`/projects/{id}/portal-preview`, `/projects/{id}/portal-link*`,
|
||||||
|
`/portal/open/*`.
|
||||||
|
|
||||||
|
**Smaller items from the pre-merge code review:**
|
||||||
|
- [ ] Keepalive isn't auto-turned-off when the last alert rule on a unit is
|
||||||
|
deleted (intentional "never auto-off"; revisit if it wastes cellular).
|
||||||
|
- [ ] Consider rate-limiting the scoped portal endpoints once public.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Terra-View Roadmap
|
||||||
|
|
||||||
|
Living document — captures known deferred work, in-flight initiatives, and longer-term ideas.
|
||||||
|
Bump items up/down or strike them through as priorities shift. Source of truth for "what's next"
|
||||||
|
should be this file plus the `## Current Development Focus` block in `CLAUDE.md`.
|
||||||
|
|
||||||
|
Last updated: 2026-06-05 (Terra-View v0.13.3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In Flight
|
||||||
|
|
||||||
|
Work that's started or has obvious next steps in the code.
|
||||||
|
|
||||||
|
- **SFM Integration Phase 2 — device control** — expose `/device/*` (start, stop, erase, push-config)
|
||||||
|
through the Terra-View proxy. Blocked on SFM growing an auth layer; placeholder TODOs already in
|
||||||
|
`backend/services/device_controller.py` (lines 73, 109, 207, 282, 582).
|
||||||
|
- **Calibration sync from SFM events** — done in v0.13.x. Daily 03:15 job + Settings "Sync now" button.
|
||||||
|
Future: surface "last sync" timestamp on unit detail; per-unit "sync this one" action.
|
||||||
|
- **Synology NAS deployment** — doc lives at `docs/SYNOLOGY_DEPLOYMENT.md`. Need to actually deploy
|
||||||
|
+ write up what tripped us up vs. the doc's expectations.
|
||||||
|
|
||||||
|
## Near-Term
|
||||||
|
|
||||||
|
Concrete things scoped but not started.
|
||||||
|
|
||||||
|
- **Migrate GPS coord parse in `photos.py`** — currently writes to dead `RosterUnit.coordinates`
|
||||||
|
field. Should write to the active `MonitoringLocation` instead (matches the location-as-truth
|
||||||
|
refactor done elsewhere). Helper: `backend/services/unit_location.py`.
|
||||||
|
- **Phase 3 — drag-to-resize deployment bars** on the fleet-wide deployment-history Gantt
|
||||||
|
(`/tools/deployment-history`). Phase 2 (the calendar + Gantt tabs) shipped in v0.12.0.
|
||||||
|
- **Phase 5c — swap-detection daily job** — placeholder card already in `templates/tools.html:162`.
|
||||||
|
Auto-detects unit swaps in the field (BE12345 → BE67890 at the same project+location) from
|
||||||
|
operator-typed metadata. Pairs with a notification inbox.
|
||||||
|
- **Geocoding for address strings** — TODO in `templates/dashboard.html:913`. Lets locations without
|
||||||
|
explicit coordinates still appear on maps.
|
||||||
|
- **ModemManager backend** — `backend/routers/modem_dashboard.py:279` has a TODO for querying a real
|
||||||
|
modem backend. Currently the modem dashboard is mostly read-only metadata.
|
||||||
|
|
||||||
|
## Medium-Term
|
||||||
|
|
||||||
|
Bigger features, sketched but not designed in detail.
|
||||||
|
|
||||||
|
- **Alerting** — email/SMS for missing units, calibration-expiring-soon, sync failures.
|
||||||
|
README's "Future Enhancements" has had this for a while; would pair well with the existing
|
||||||
|
`UserPreferences` thresholds.
|
||||||
|
- **Multi-user auth** — currently single-tenant, no login. Probably the prerequisite for any
|
||||||
|
cloud-hosted multi-customer deployment.
|
||||||
|
- **Notification inbox** — central place for swap-detection alerts, sync errors, calibration
|
||||||
|
warnings, FT-flag review queue, etc.
|
||||||
|
- **Audit log UI** — `UnitHistory` already records everything; expose a filterable view.
|
||||||
|
|
||||||
|
## Long-Term / Wishlist
|
||||||
|
|
||||||
|
Speculative. Promote up the list once there's a concrete need.
|
||||||
|
|
||||||
|
- PostgreSQL backend for larger deployments (SQLite is fine for now)
|
||||||
|
- Advanced filtering / saved searches on roster + events
|
||||||
|
- Export roster in additional formats (XLSX, GeoJSON)
|
||||||
|
- Public-facing project status pages (read-only, share-link gated)
|
||||||
|
- SLM module parity with seismographs — modal-based event/measurement detail similar to SFM modal
|
||||||
|
- Weather station / accelerometer / GPS tracker modules (new device-type modules following the
|
||||||
|
SLMM pattern — see `CLAUDE.md` → "Adding a New Device Type Module")
|
||||||
|
|
||||||
|
## Done / Reference
|
||||||
|
|
||||||
|
For shipped items, see `CHANGELOG.md`. For architecture decisions, see `CLAUDE.md`.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# FTP Night-Report Pipeline — changelog entry
|
||||||
|
|
||||||
|
> **How to use:** paste the block below into Terra-View's `CHANGELOG.md`.
|
||||||
|
> The current `[Unreleased]` section targets **0.14.0** (SLM live monitoring); this
|
||||||
|
> is a separate, larger feature, so it's drafted here as **0.15.0** — fold it into
|
||||||
|
> `[Unreleased]` or bump the version as you prefer. Set the release date when you ship.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.15.0] - 2026-XX-XX
|
||||||
|
|
||||||
|
FTP night-report pipeline. Automated **daily morning report** of last night's noise (7PM–7AM) versus a baseline, per location, for 24/7 remote sound jobs. The meter records 24/7 to its SD card regardless of TCP state, so the report pulls the meter's own stored 15-minute `_Leq_` intervals over FTP (through the existing `/api/slmm/.../ftp/...` proxy) — accurate Leq/Lmax/Ln straight from the device, and resilient to a TCP-control wedge. The report engine is source-agnostic and metric-driven; delivery is an HTML email body plus an Excel attachment. Built around the existing `MonitoringSession`/`DataFile` store and the existing scheduled `cycle` action — the meter is cycled each morning (stop → download → ingest → increment store index → restart), and the report runs off the just-finished, finalized folder.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Callable ingest — `ingest_nrl_zip(location_id, zip_bytes, db)`** (`backend/routers/project_locations.py`). The manual SD-card upload (`upload_nrl_data`) was refactored into a shared core so the same path runs programmatically from the scheduler. Keeps `.rnh` + the averaged `_Leq_ .rnd`, drops the 1-second `_Lp_` files, parses the header (now also capturing the device's **percentile→slot map** and weightings into session metadata), and **dedups** repeated pulls of the same folder by store-name + start time. Metric-agnostic: every column of the Leq file is preserved on disk; metric selection happens in the report layer.
|
||||||
|
- **Report compute engine** (`backend/services/report_pipeline.py`). Per-location night model: **LAmax / LA01 / LA10 / LA90 / LAeq** over **Evening (7–10PM)** and **Nighttime (10PM–7AM)** windows, with correct aggregation — Lmax = loudest interval, percentiles = arithmetic mean, **Leq = logarithmic (energy) mean**. The LN→percentile mapping is read from the device's own `.rnh` config, not hardcoded.
|
||||||
|
- **Two baseline sources.** *Captured* — computed from recorded nights in a configurable date range (the "typical night" = mean of per-night values). *Reference* — fixed values typed per location, for a spec limit (e.g. *"L10 = 85"*) or a prior report's averages when the raw data isn't in the system. Blank reference cells aren't compared.
|
||||||
|
- **Renderers** (`backend/services/report_renderers.py`). HTML email body (per-location Last / Baseline / Δ table, colored louder/quieter) **+ an Excel attachment** — one worksheet per NRL with the 15-minute interval table, a line chart, and a Last/Base/Δ summary per window. Metric-driven, so it tracks whatever metric set is configured.
|
||||||
|
- **Config-driven SMTP sender** (`backend/services/report_email.py`). Reads host/port/security/user/password/from/recipients from env (`REPORT_SMTP_*`); **dry-run** when unconfigured, so reports still generate and persist without credentials.
|
||||||
|
- **Per-project config + automatic morning run.** New `SoundReportConfig` table (enabled, report time, metrics, baseline mode + range, recipients) and a scheduler tick (`SchedulerService.run_due_reports`) that builds + emails each enabled project's report once per morning, off the event loop. The orchestrator (`report_orchestrator.py`) always writes `report.html` / `report.json` / `report.xlsx` to `data/reports/{project}/{date}/`, then emails.
|
||||||
|
- **Capture hook in the daily cycle.** `_execute_cycle` now ingests the just-finished `Auto_####` folder into Terra-View after the download, and verifies the meter resumed measuring via a fresh DOD (`measurement_state`) — alerting if not.
|
||||||
|
- **UI on the sound project header.** A **Night Report** button (modal: view a night, *Run & Email* on demand, and a *Recent reports* list with HTML + Excel links) and a **gear → Settings** modal (enable/time, **baseline source toggle** with a per-NRL value editor, metrics, recipients, a **Send test email** button, and a schedule/last-run status line).
|
||||||
|
- **Endpoints** (`backend/routers/reports.py`): `GET/PUT …/reports/config`, `GET/PUT …/reports/baseline`, `GET …/reports/nightly/view`, `POST …/reports/nightly/run`, `POST …/reports/test-email`, `GET …/reports/list`, `GET …/reports/archive/{date}` (+ `/xlsx`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Manual SD upload now shares the new ingest core.** `POST …/nrl/{location_id}/upload-data` behaves as before (zip or loose files) but routes through `_ingest_file_entries`, so manually-uploaded sessions also get the captured percentile map.
|
||||||
|
|
||||||
|
### Security / hardening
|
||||||
|
|
||||||
|
- HTML modal fields built from user-controlled data (location names, baseline values) are HTML-escaped before insertion (stored-XSS fix).
|
||||||
|
- The SMTP sender refuses to silently downgrade to a plaintext connection on an unrecognized `REPORT_SMTP_SECURITY` value (falls back to STARTTLS), and warns when credentials would go over an unencrypted link.
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
- **No database migration.** `sound_report_configs` is a brand-new table created automatically by `create_all` on startup (the `baseline_mode` column lives on it). Templates and Python are baked into the image, so **rebuild** (don't just restart):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
- **To actually send email**, set the relay env vars (e.g. on the `terra-view` service in `docker-compose.yml`). Until then, reports still build and write to `data/reports/…` in dry-run:
|
||||||
|
|
||||||
|
```
|
||||||
|
REPORT_SMTP_HOST, REPORT_SMTP_PORT, REPORT_SMTP_SECURITY=starttls|ssl|none,
|
||||||
|
REPORT_SMTP_USER, REPORT_SMTP_PASSWORD, REPORT_SMTP_FROM, REPORT_SMTP_RECIPIENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
Use **Settings → Send test email** to verify the relay once set.
|
||||||
|
|
||||||
|
- **To turn on automation for a job:** configure a daily `cycle` recurring schedule per NRL (~7:15 AM, after the night ends) so the meter is stopped/downloaded/ingested/restarted, then **enable** the report in the gear (report time ~8 AM) and set the baseline (range or fixed values).
|
||||||
|
|
||||||
|
- **Not yet field-tested on a physical meter** — the live device-control portion of the cycle hook (download + restart-verify) was validated against a mocked SLMM only.
|
||||||
@@ -9,3 +9,4 @@ Pillow==10.1.0
|
|||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
rapidfuzz==3.10.1
|
rapidfuzz==3.10.1
|
||||||
|
schedule==1.2.2
|
||||||
|
|||||||
@@ -0,0 +1,711 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Deployment History - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.dh-calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 1280px) { .dh-calendar-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
@media (max-width: 768px) { .dh-calendar-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 480px) { .dh-calendar-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.dh-month-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.dark .dh-month-card { background: rgb(30 41 59); }
|
||||||
|
|
||||||
|
.dh-day-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.dh-day-header {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.dark .dh-day-header { color: #9ca3af; }
|
||||||
|
|
||||||
|
.dh-day-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding-top: 2px;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dh-day-cell:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.dh-day-cell.empty {
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.dh-day-cell.empty:hover { transform: none; }
|
||||||
|
.dh-day-cell.today {
|
||||||
|
ring-color: #f48b1c;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b84a12;
|
||||||
|
}
|
||||||
|
.dark .dh-day-cell {
|
||||||
|
background-color: rgba(55, 65, 81, 0.5);
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.dh-day-cell .dh-day-num {
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dh-day-cell .dh-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
margin: 2px 2px 0 2px;
|
||||||
|
}
|
||||||
|
.dh-bar {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6 flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Deployment History</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Where every unit has been — actual assignment windows, color-coded by project.
|
||||||
|
For future / planned deployments use the <a href="/fleet-calendar" class="text-seismo-orange hover:text-seismo-burgundy">Job Planner</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View tabs: Calendar | Gantt (by project) | By Unit (gantt by unit) -->
|
||||||
|
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
|
||||||
|
<button id="dh-tab-calendar" onclick="switchDhView('calendar')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
|
||||||
|
Calendar
|
||||||
|
</button>
|
||||||
|
<button id="dh-tab-gantt" onclick="switchDhView('gantt')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Gantt by Project
|
||||||
|
</button>
|
||||||
|
<button id="dh-tab-byunit" onclick="switchDhView('byunit')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Gantt by Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI strip -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||||
|
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }}</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_active_units }} unique units</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window</span>
|
||||||
|
</div>
|
||||||
|
{% if calendar.projects %}
|
||||||
|
<details class="bg-white dark:bg-slate-800 rounded-lg shadow px-4 py-2">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 select-none">Project legend</summary>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
{% for p in calendar.projects %}
|
||||||
|
<a href="/projects/{{ p.id }}"
|
||||||
|
class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-seismo-orange">
|
||||||
|
<span class="w-3 h-1.5 rounded-full flex-shrink-0" style="background-color: {{ p.color }};"></span>
|
||||||
|
{{ p.name }}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">·</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ p.assignment_count }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Calendar view ─── -->
|
||||||
|
<div id="dh-view-calendar">
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
{% if calendar.projects %}
|
||||||
|
<div class="dh-calendar-grid mb-6">
|
||||||
|
{% for month_data in calendar.months %}
|
||||||
|
<div class="dh-month-card">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
|
||||||
|
{{ month_data.short_name }} '{{ month_data.year_short }}
|
||||||
|
</h3>
|
||||||
|
<div class="dh-day-grid">
|
||||||
|
<div class="dh-day-header">S</div>
|
||||||
|
<div class="dh-day-header">M</div>
|
||||||
|
<div class="dh-day-header">T</div>
|
||||||
|
<div class="dh-day-header">W</div>
|
||||||
|
<div class="dh-day-header">T</div>
|
||||||
|
<div class="dh-day-header">F</div>
|
||||||
|
<div class="dh-day-header">S</div>
|
||||||
|
|
||||||
|
{# Sunday-first alignment: shift Monday=0 → Sunday=0 #}
|
||||||
|
{% set first_offset = (month_data.first_weekday + 1) % 7 %}
|
||||||
|
{% for i in range(first_offset) %}
|
||||||
|
<div class="dh-day-cell empty"></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for day_num in range(1, month_data.num_days + 1) %}
|
||||||
|
{% set date_str = '%04d-%02d-%02d' | format(month_data.year, month_data.month, day_num) %}
|
||||||
|
{% set is_today = date_str == today %}
|
||||||
|
{% set day_proj_ids = month_data.active_days.get(day_num, []) %}
|
||||||
|
<div class="dh-day-cell{% if is_today %} today ring-2 ring-seismo-orange{% endif %}"
|
||||||
|
onclick="openDhDay('{{ date_str }}')"
|
||||||
|
title="{{ date_str }} — {{ day_proj_ids | length }} project{{ '' if day_proj_ids | length == 1 else 's' }}">
|
||||||
|
<span class="dh-day-num">{{ day_num }}</span>
|
||||||
|
{% if day_proj_ids %}
|
||||||
|
<span class="dh-bars">
|
||||||
|
{% for pid in day_proj_ids[:4] %}
|
||||||
|
{% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %}
|
||||||
|
{% if p %}
|
||||||
|
<span class="dh-bar" style="background-color: {{ p.color }};"
|
||||||
|
title="{{ p.name }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if day_proj_ids | length > 4 %}
|
||||||
|
<span class="text-[8px] text-gray-500 leading-none">+{{ day_proj_ids | length - 4 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Month navigation -->
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Previous month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||||
|
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
||||||
|
</span>
|
||||||
|
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Next month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/tools/deployment-history"
|
||||||
|
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||||
|
Recent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No deployments in this window.</p>
|
||||||
|
<p class="text-xs mt-1">Try the navigation buttons below to look at a different range.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div> {# /#dh-view-calendar #}
|
||||||
|
|
||||||
|
<!-- ─── Gantt view ─── -->
|
||||||
|
<div id="dh-view-gantt" class="hidden">
|
||||||
|
{% if calendar.projects %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
One row per project. Each bar is one assignment window (unit at a location).
|
||||||
|
Hover for details, click to open the project.
|
||||||
|
Active deployments end at "now" with a small white tip.
|
||||||
|
</p>
|
||||||
|
<svg id="dh-gantt-svg" preserveAspectRatio="none"
|
||||||
|
style="width: 100%; min-width: 800px;"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Same nav as calendar view, repeated here so the Gantt view has its own. -->
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#gantt"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Previous month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||||
|
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
||||||
|
</span>
|
||||||
|
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#gantt"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Next month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/tools/deployment-history#gantt"
|
||||||
|
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||||
|
Recent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-sm">No deployments in this window.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div> {# /#dh-view-gantt #}
|
||||||
|
|
||||||
|
<!-- ─── Gantt-by-Unit view ─── -->
|
||||||
|
<div id="dh-view-byunit" class="hidden">
|
||||||
|
{% if calendar.units %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
One row per seismograph that had ≥1 assignment in this window.
|
||||||
|
Bars are colored by <strong>project</strong>, so an operator can read where each unit travelled
|
||||||
|
across jobs. Active deployments end at "now" with a small white tip.
|
||||||
|
Click a unit ID to open its detail page; click a bar to open the bar's project.
|
||||||
|
</p>
|
||||||
|
<svg id="dh-byunit-svg" preserveAspectRatio="none"
|
||||||
|
style="width: 100%; min-width: 800px;"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#byunit"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Previous month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||||
|
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
||||||
|
</span>
|
||||||
|
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#byunit"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Next month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/tools/deployment-history#byunit"
|
||||||
|
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||||
|
Recent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-sm">No unit deployments in this window.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div> {# /#dh-view-byunit #}
|
||||||
|
|
||||||
|
<!-- Day-detail side panel -->
|
||||||
|
<div id="dh-day-panel-backdrop"
|
||||||
|
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
|
||||||
|
onclick="closeDhDayPanel()"></div>
|
||||||
|
<div id="dh-day-panel"
|
||||||
|
class="fixed top-0 right-0 h-screen w-full max-w-md bg-white dark:bg-slate-800 shadow-2xl z-50 hidden transform translate-x-full transition-transform duration-300 overflow-y-auto">
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" id="dh-day-panel-title">Deployments on…</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400" id="dh-day-panel-subtitle"></p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeDhDayPanel()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="dh-day-panel-body" class="p-5 space-y-3">
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function _dhEsc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDhDay(dateStr) {
|
||||||
|
const panel = document.getElementById('dh-day-panel');
|
||||||
|
const backdrop = document.getElementById('dh-day-panel-backdrop');
|
||||||
|
const title = document.getElementById('dh-day-panel-title');
|
||||||
|
const sub = document.getElementById('dh-day-panel-subtitle');
|
||||||
|
const body = document.getElementById('dh-day-panel-body');
|
||||||
|
|
||||||
|
title.textContent = 'Deployments on ' + dateStr;
|
||||||
|
sub.textContent = '';
|
||||||
|
body.innerHTML = `<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
|
||||||
|
Loading…
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
backdrop.classList.remove('hidden');
|
||||||
|
requestAnimationFrame(() => panel.classList.remove('translate-x-full'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/deployment-history/day?target_date=${encodeURIComponent(dateStr)}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const d = await r.json();
|
||||||
|
renderDhDayPanel(d, dateStr);
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `<p class="text-sm text-red-500">Failed to load: ${_dhEsc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDhDayPanel(d, dateStr) {
|
||||||
|
const sub = document.getElementById('dh-day-panel-subtitle');
|
||||||
|
const body = document.getElementById('dh-day-panel-body');
|
||||||
|
|
||||||
|
sub.textContent = `${d.count} active deployment${d.count === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
if (d.count === 0) {
|
||||||
|
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No active deployments on this date.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by project for cleaner display.
|
||||||
|
const byProj = {};
|
||||||
|
for (const dep of d.deployments) {
|
||||||
|
const key = dep.project_id || '_none';
|
||||||
|
if (!byProj[key]) byProj[key] = { name: dep.project_name, color: dep.project_color, items: [] };
|
||||||
|
byProj[key].items.push(dep);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const pid of Object.keys(byProj)) {
|
||||||
|
const proj = byProj[pid];
|
||||||
|
html += `<div class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href="/projects/${_dhEsc(pid)}" class="flex items-center gap-2 font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||||
|
<span class="w-3 h-3 rounded-full" style="background-color: ${proj.color};"></span>
|
||||||
|
${_dhEsc(proj.name)}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">${proj.items.length} unit${proj.items.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 ml-5">`;
|
||||||
|
for (const dep of proj.items) {
|
||||||
|
const activeBadge = dep.is_active
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||||
|
: '';
|
||||||
|
const sourceTag = dep.source === 'metadata_backfill'
|
||||||
|
? '<span class="text-[10px] text-blue-600 dark:text-blue-400 italic">auto-backfilled</span>'
|
||||||
|
: '';
|
||||||
|
const windowEnd = dep.is_active ? 'present' : (dep.assigned_until || '').slice(0, 10);
|
||||||
|
const windowStart = (dep.assigned_at || '').slice(0, 10);
|
||||||
|
html += `<div class="flex items-center justify-between gap-2 py-1 text-sm">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/unit/${_dhEsc(dep.unit_id)}" class="font-mono font-medium text-seismo-orange hover:text-seismo-navy">${_dhEsc(dep.unit_id)}</a>
|
||||||
|
${activeBadge}
|
||||||
|
${sourceTag}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
📍 <a href="/projects/${_dhEsc(pid)}/nrl/${_dhEsc(dep.location_id)}" class="hover:text-seismo-orange">${_dhEsc(dep.location_name)}</a>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">${windowStart} → ${windowEnd}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += `</div></div>`;
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDhDayPanel() {
|
||||||
|
const panel = document.getElementById('dh-day-panel');
|
||||||
|
const backdrop = document.getElementById('dh-day-panel-backdrop');
|
||||||
|
panel.classList.add('translate-x-full');
|
||||||
|
setTimeout(() => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
backdrop.classList.add('hidden');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeDhDayPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tab switcher ────────────────────────────────────────────────────
|
||||||
|
const _DH_TABS = {
|
||||||
|
calendar: { view: 'dh-view-calendar', btn: 'dh-tab-calendar', hash: '', render: null },
|
||||||
|
gantt: { view: 'dh-view-gantt', btn: 'dh-tab-gantt', hash: '#gantt', render: () => renderDhGantt() },
|
||||||
|
byunit: { view: 'dh-view-byunit', btn: 'dh-tab-byunit', hash: '#byunit', render: () => renderDhByUnit() },
|
||||||
|
};
|
||||||
|
|
||||||
|
function switchDhView(which) {
|
||||||
|
const target = _DH_TABS[which] || _DH_TABS.calendar;
|
||||||
|
const activeCls = ['bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow'];
|
||||||
|
const dormantCls = ['text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white'];
|
||||||
|
// Hide all views, dormant-style all tabs, then activate the chosen one.
|
||||||
|
for (const key of Object.keys(_DH_TABS)) {
|
||||||
|
const t = _DH_TABS[key];
|
||||||
|
const v = document.getElementById(t.view);
|
||||||
|
const b = document.getElementById(t.btn);
|
||||||
|
if (v) v.classList.add('hidden');
|
||||||
|
if (b) { b.classList.remove(...activeCls); b.classList.add(...dormantCls); }
|
||||||
|
}
|
||||||
|
const v = document.getElementById(target.view);
|
||||||
|
const b = document.getElementById(target.btn);
|
||||||
|
if (v) v.classList.remove('hidden');
|
||||||
|
if (b) { b.classList.add(...activeCls); b.classList.remove(...dormantCls); }
|
||||||
|
// URL hash sync.
|
||||||
|
const wanted = target.hash;
|
||||||
|
const cur = window.location.hash || '';
|
||||||
|
if (cur !== wanted) {
|
||||||
|
history.replaceState(null, '', window.location.pathname + window.location.search + wanted);
|
||||||
|
}
|
||||||
|
if (target.render) target.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Land on the requested view if the URL hash matches.
|
||||||
|
const _initialHash = window.location.hash;
|
||||||
|
if (_initialHash === '#gantt' || _initialHash === '#byunit') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => switchDhView(_initialHash.slice(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gantt renderer ──────────────────────────────────────────────────
|
||||||
|
// Server-rendered data: per-project list with each project's bars
|
||||||
|
// (assignment windows clipped to the visible window).
|
||||||
|
const _dhProjects = {{ calendar.projects | tojson }};
|
||||||
|
const _dhWindowStart = {{ calendar.window.first_date | tojson }};
|
||||||
|
const _dhWindowEnd = {{ calendar.window.last_date | tojson }};
|
||||||
|
|
||||||
|
let _ganttRendered = false;
|
||||||
|
function renderDhGantt() {
|
||||||
|
if (_ganttRendered) return; // build once; data doesn't change after page load
|
||||||
|
_ganttRendered = true;
|
||||||
|
const svg = document.getElementById('dh-gantt-svg');
|
||||||
|
if (!svg) return;
|
||||||
|
if (!_dhProjects || _dhProjects.length === 0) {
|
||||||
|
svg.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||||||
|
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||||||
|
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
|
||||||
|
const todayColor = '#f48b1c';
|
||||||
|
|
||||||
|
// Geometry. The Gantt SVG has a left "labels" gutter and a right
|
||||||
|
// time-axis area. Width is fluid (matches container); height grows
|
||||||
|
// with the number of project rows.
|
||||||
|
const containerW = svg.parentElement.clientWidth || 1000;
|
||||||
|
const width = Math.max(containerW, 800);
|
||||||
|
const labelW = 220;
|
||||||
|
const padTop = 36; // room for month labels above
|
||||||
|
const padBottom = 16;
|
||||||
|
const rowH = 36;
|
||||||
|
const barH = 18;
|
||||||
|
const height = padTop + padBottom + _dhProjects.length * rowH;
|
||||||
|
|
||||||
|
const usableW = width - labelW - 8;
|
||||||
|
|
||||||
|
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
|
||||||
|
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
|
||||||
|
const tRange = tEnd - tStart;
|
||||||
|
const xFor = (d) => {
|
||||||
|
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
|
||||||
|
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Month gridlines + month labels along the top.
|
||||||
|
// Walk every 1st-of-month inside the window.
|
||||||
|
const monthsParts = [];
|
||||||
|
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
|
||||||
|
cursor.setUTCDate(1);
|
||||||
|
while (cursor.getTime() <= tEnd) {
|
||||||
|
const x = xFor(cursor);
|
||||||
|
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||||
|
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||||
|
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today marker.
|
||||||
|
const now = Date.now();
|
||||||
|
let todayMarker = '';
|
||||||
|
if (now >= tStart && now <= tEnd) {
|
||||||
|
const x = xFor(now);
|
||||||
|
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
|
||||||
|
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build rows.
|
||||||
|
const rowParts = [];
|
||||||
|
_dhProjects.forEach((proj, idx) => {
|
||||||
|
const y = padTop + idx * rowH;
|
||||||
|
// Alternating row background.
|
||||||
|
if (idx % 2 === 1) {
|
||||||
|
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
||||||
|
}
|
||||||
|
// Project label (clipped to gutter, clickable).
|
||||||
|
const labelText = proj.name.length > 28 ? proj.name.slice(0, 26) + '…' : proj.name;
|
||||||
|
rowParts.push(`<a href="/projects/${_dhEsc(proj.id)}" target="_top">
|
||||||
|
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
||||||
|
<circle cx="14" cy="${y + rowH / 2}" r="4" fill="${proj.color}"/>
|
||||||
|
<text x="24" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelColor}"
|
||||||
|
font-family="system-ui,sans-serif"
|
||||||
|
title="${_dhEsc(proj.name)}">
|
||||||
|
${_dhEsc(labelText)}
|
||||||
|
</text>
|
||||||
|
</a>`);
|
||||||
|
// Bars for each assignment in this project.
|
||||||
|
for (const bar of (proj.bars || [])) {
|
||||||
|
const x1 = xFor(bar.start);
|
||||||
|
const x2 = xFor(bar.end);
|
||||||
|
const barW = Math.max(x2 - x1, 2);
|
||||||
|
const by = y + (rowH - barH) / 2;
|
||||||
|
const tip = `${bar.unit_id} @ ${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
|
||||||
|
rowParts.push(`<g style="cursor: pointer;">
|
||||||
|
<title>${_dhEsc(tip)}</title>
|
||||||
|
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
||||||
|
rx="3"
|
||||||
|
fill="${proj.color}" opacity="${bar.is_active ? 1.0 : 0.75}"
|
||||||
|
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
|
||||||
|
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
|
||||||
|
</g>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gantt-by-Unit renderer ──────────────────────────────────────────
|
||||||
|
// Inverts the Gantt: rows = seismographs, bars = assignment windows
|
||||||
|
// colored by project (so an operator can read where each unit went
|
||||||
|
// across jobs). Shares the time-axis geometry with renderDhGantt().
|
||||||
|
const _dhUnits = {{ calendar.units | tojson }};
|
||||||
|
let _byunitRendered = false;
|
||||||
|
function renderDhByUnit() {
|
||||||
|
if (_byunitRendered) return;
|
||||||
|
_byunitRendered = true;
|
||||||
|
const svg = document.getElementById('dh-byunit-svg');
|
||||||
|
if (!svg) return;
|
||||||
|
if (!_dhUnits || _dhUnits.length === 0) {
|
||||||
|
svg.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||||||
|
const labelStrong = isDark ? '#e5e7eb' : '#111827';
|
||||||
|
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||||||
|
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
|
||||||
|
const todayColor = '#f48b1c';
|
||||||
|
|
||||||
|
const containerW = svg.parentElement.clientWidth || 1000;
|
||||||
|
const width = Math.max(containerW, 800);
|
||||||
|
const labelW = 160;
|
||||||
|
const padTop = 36;
|
||||||
|
const padBottom = 16;
|
||||||
|
const rowH = 32;
|
||||||
|
const barH = 16;
|
||||||
|
const height = padTop + padBottom + _dhUnits.length * rowH;
|
||||||
|
|
||||||
|
const usableW = width - labelW - 8;
|
||||||
|
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
|
||||||
|
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
|
||||||
|
const tRange = tEnd - tStart;
|
||||||
|
const xFor = (d) => {
|
||||||
|
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
|
||||||
|
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Month gridlines + labels (same as renderDhGantt).
|
||||||
|
const monthsParts = [];
|
||||||
|
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
|
||||||
|
cursor.setUTCDate(1);
|
||||||
|
while (cursor.getTime() <= tEnd) {
|
||||||
|
const x = xFor(cursor);
|
||||||
|
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||||
|
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||||
|
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let todayMarker = '';
|
||||||
|
if (now >= tStart && now <= tEnd) {
|
||||||
|
const x = xFor(now);
|
||||||
|
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
|
||||||
|
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowParts = [];
|
||||||
|
_dhUnits.forEach((unit, idx) => {
|
||||||
|
const y = padTop + idx * rowH;
|
||||||
|
if (idx % 2 === 1) {
|
||||||
|
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
||||||
|
}
|
||||||
|
// Unit label: opens /unit/{id} when clicked. Active-now dot.
|
||||||
|
const activeDot = unit.any_active
|
||||||
|
? `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="#22c55e"/>`
|
||||||
|
: `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="${labelColor}" opacity="0.4"/>`;
|
||||||
|
rowParts.push(`<a href="/unit/${_dhEsc(unit.id)}" target="_top">
|
||||||
|
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
||||||
|
${activeDot}
|
||||||
|
<text x="22" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelStrong}"
|
||||||
|
font-family="system-ui,sans-serif" font-weight="500">
|
||||||
|
${_dhEsc(unit.id)}
|
||||||
|
</text>
|
||||||
|
<text x="${labelW - 8}" y="${y + rowH / 2 + 4}" font-size="10" fill="${labelColor}"
|
||||||
|
text-anchor="end" font-family="system-ui,sans-serif">
|
||||||
|
${unit.assignment_count}
|
||||||
|
</text>
|
||||||
|
</a>`);
|
||||||
|
// Bars — colored by the bar's project (not the row).
|
||||||
|
for (const bar of (unit.bars || [])) {
|
||||||
|
const x1 = xFor(bar.start);
|
||||||
|
const x2 = xFor(bar.end);
|
||||||
|
const barW = Math.max(x2 - x1, 2);
|
||||||
|
const by = y + (rowH - barH) / 2;
|
||||||
|
const tip = `${bar.project_name}\n${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
|
||||||
|
rowParts.push(`<a href="/projects/${_dhEsc(bar.project_id)}" target="_top">
|
||||||
|
<g style="cursor: pointer;">
|
||||||
|
<title>${_dhEsc(tip)}</title>
|
||||||
|
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
||||||
|
rx="3"
|
||||||
|
fill="${bar.project_color}" opacity="${bar.is_active ? 1.0 : 0.75}"
|
||||||
|
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
|
||||||
|
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
|
||||||
|
</g>
|
||||||
|
</a>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -275,7 +275,13 @@ async function _fetchTypeahead(input, fieldKind) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Args go in data-* attributes (not inline onclick) to avoid the quote
|
||||||
|
// collision when location names contain characters JSON.stringify quotes
|
||||||
|
// (e.g. anything with spaces/punctuation — basically every real name).
|
||||||
|
// _esc() escapes for HTML attribute context (entities for <>&"), then
|
||||||
|
// the browser decodes them when reading the dataset value.
|
||||||
dropdown.innerHTML = items.map((it, idx) => {
|
dropdown.innerHTML = items.map((it, idx) => {
|
||||||
|
const cid = _esc(input.dataset.clusterId);
|
||||||
if (it.kind === 'match') {
|
if (it.kind === 'match') {
|
||||||
const m = it.payload;
|
const m = it.payload;
|
||||||
const scoreBadge = m.score >= 0.99
|
const scoreBadge = m.score >= 0.99
|
||||||
@@ -291,16 +297,24 @@ async function _fetchTypeahead(input, fieldKind) {
|
|||||||
}
|
}
|
||||||
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||||
return `<button type="button"
|
return `<button type="button"
|
||||||
|
data-cid="${cid}"
|
||||||
|
data-field-kind="${fieldKind}"
|
||||||
|
data-entity-id="${_esc(m.id)}"
|
||||||
|
data-entity-name="${_esc(m.name)}"
|
||||||
onmousedown="event.preventDefault()"
|
onmousedown="event.preventDefault()"
|
||||||
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})"
|
onclick="_typeaheadPickFromButton(this)"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
|
||||||
${metaLine}
|
${metaLine}
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
return `<button type="button"
|
return `<button type="button"
|
||||||
|
data-cid="${cid}"
|
||||||
|
data-field-kind="${fieldKind}"
|
||||||
|
data-entity-id=""
|
||||||
|
data-entity-name="${_esc(it.name)}"
|
||||||
onmousedown="event.preventDefault()"
|
onmousedown="event.preventDefault()"
|
||||||
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})"
|
onclick="_typeaheadPickFromButton(this)"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
|
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
|
||||||
+ ${_esc(it.label)}
|
+ ${_esc(it.label)}
|
||||||
</button>`;
|
</button>`;
|
||||||
@@ -308,6 +322,19 @@ async function _fetchTypeahead(input, fieldKind) {
|
|||||||
dropdown.classList.remove('hidden');
|
dropdown.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trampoline — reads the click target's data-* attributes and forwards
|
||||||
|
// to onTypeaheadPick. Keeps the inline onclick attribute free of any
|
||||||
|
// string interpolation that could collide with HTML quoting.
|
||||||
|
function _typeaheadPickFromButton(btn) {
|
||||||
|
onTypeaheadPick(
|
||||||
|
null,
|
||||||
|
btn.dataset.cid,
|
||||||
|
btn.dataset.fieldKind,
|
||||||
|
btn.dataset.entityId || '',
|
||||||
|
btn.dataset.entityName || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
|
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
|
||||||
// entityId is empty string for "create new", or a UUID for matched existing.
|
// entityId is empty string for "create new", or a UUID for matched existing.
|
||||||
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
|
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Pending Deployments - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Pending Deployments</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Captures from the field waiting to be classified.
|
||||||
|
<a href="/deploy" class="text-seismo-orange hover:text-seismo-navy">Capture a new one →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter pills -->
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<button onclick="switchPdStatus('awaiting')" id="pd-tab-awaiting"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-seismo-orange text-white">
|
||||||
|
Awaiting <span id="pd-count-awaiting" class="ml-1 text-xs opacity-80"></span>
|
||||||
|
</button>
|
||||||
|
<button onclick="switchPdStatus('assigned')" id="pd-tab-assigned"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Assigned
|
||||||
|
</button>
|
||||||
|
<button onclick="switchPdStatus('cancelled')" id="pd-tab-cancelled"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Cancelled
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pd-list" class="space-y-4">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Classify modal -->
|
||||||
|
<div id="classify-modal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="min-height: 480px;">
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Classify pending deployment</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span id="classify-unit-label" class="font-mono text-seismo-orange"></span>
|
||||||
|
captured at
|
||||||
|
<span id="classify-captured-at"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeClassifyModal()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5 space-y-5">
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="setClassifyMode('existing')" id="mode-existing"
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white">
|
||||||
|
Assign to existing location
|
||||||
|
</button>
|
||||||
|
<button onclick="setClassifyMode('new')" id="mode-new"
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Create new location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing mode: project + location pickers -->
|
||||||
|
<div id="classify-existing-pane" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||||
|
<select id="existing-project-select" onchange="onExistingProjectChange()"
|
||||||
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<option value="">Loading projects…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
|
||||||
|
<select id="existing-location-select"
|
||||||
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<option value="">Pick a project first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New mode: project (existing/new) + location name -->
|
||||||
|
<div id="classify-new-pane" class="hidden space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
|
<select id="new-project-select" onchange="onNewProjectMode()"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<option value="">— Create new project —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="new-project-name-wrap" class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">New project name</label>
|
||||||
|
<input id="new-project-name" type="text" placeholder="e.g. Carnegie Museum HVAC"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Project type will be Vibration Monitoring.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location name</label>
|
||||||
|
<input id="new-location-name" type="text" placeholder="e.g. NE corner, near loading dock"
|
||||||
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" id="use-captured-coords" checked
|
||||||
|
class="rounded border-gray-300 text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
Use the photo's GPS coords <span id="captured-coords-hint" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Assignment notes (optional)</label>
|
||||||
|
<textarea id="classify-notes" rows="2"
|
||||||
|
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="classify-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
|
||||||
|
<button onclick="closeClassifyModal()"
|
||||||
|
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="classify-submit" onclick="submitClassify()"
|
||||||
|
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||||
|
Classify
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let _pdState = {
|
||||||
|
currentStatus: 'awaiting',
|
||||||
|
rows: [],
|
||||||
|
classifyingId: null,
|
||||||
|
classifyingPd: null,
|
||||||
|
classifyMode: 'existing', // 'existing' | 'new'
|
||||||
|
projectsCache: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtDateTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return iso.replace('T', ' ').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPdList() {
|
||||||
|
const list = document.getElementById('pd-list');
|
||||||
|
list.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/pending?status=${encodeURIComponent(_pdState.currentStatus)}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
_pdState.rows = data.pending_deployments || [];
|
||||||
|
renderPdList();
|
||||||
|
// Refresh awaiting count badge.
|
||||||
|
if (_pdState.currentStatus === 'awaiting') {
|
||||||
|
document.getElementById('pd-count-awaiting').textContent = data.count > 0 ? `(${data.count})` : '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Load failed: ${_esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPdList() {
|
||||||
|
const list = document.getElementById('pd-list');
|
||||||
|
if (_pdState.rows.length === 0) {
|
||||||
|
const blurb = {
|
||||||
|
awaiting: 'No captures awaiting classification.',
|
||||||
|
assigned: 'No assigned captures yet.',
|
||||||
|
cancelled: 'No cancelled captures.',
|
||||||
|
}[_pdState.currentStatus] || '';
|
||||||
|
list.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${blurb}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = _pdState.rows.map(pd => _renderPdCard(pd)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderPdCard(pd) {
|
||||||
|
const photoUrl = pd.photo_url || '';
|
||||||
|
const coords = pd.coordinates
|
||||||
|
? `<span class="font-mono text-xs">${_esc(pd.coordinates)}</span>`
|
||||||
|
: '<span class="text-xs italic text-gray-400">no GPS in photo</span>';
|
||||||
|
const noteHtml = pd.operator_note
|
||||||
|
? `<p class="text-xs text-gray-600 dark:text-gray-300 mt-1 italic">"${_esc(pd.operator_note)}"</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
let footerActions = '';
|
||||||
|
if (pd.status === 'awaiting') {
|
||||||
|
footerActions = `<div class="flex gap-2 mt-3">
|
||||||
|
<button onclick="openClassifyModal('${_esc(pd.id)}')"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg font-medium">
|
||||||
|
Classify
|
||||||
|
</button>
|
||||||
|
<button onclick="cancelPending('${_esc(pd.id)}')"
|
||||||
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (pd.status === 'assigned') {
|
||||||
|
footerActions = `<div class="mt-3 text-xs text-green-700 dark:text-green-400">
|
||||||
|
Promoted ${_fmtDateTime(pd.promoted_at)} → assignment <span class="font-mono">${_esc((pd.resulting_assignment_id || '').slice(0, 8))}…</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (pd.status === 'cancelled') {
|
||||||
|
footerActions = `<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex gap-4">
|
||||||
|
<div class="shrink-0 w-32 h-32 bg-gray-100 dark:bg-slate-900 rounded-lg overflow-hidden">
|
||||||
|
${photoUrl
|
||||||
|
? `<img src="${_esc(photoUrl)}" class="w-full h-full object-cover" alt="install">`
|
||||||
|
: `<div class="w-full h-full flex items-center justify-center text-gray-400 text-xs">(no photo)</div>`}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/unit/${_esc(pd.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">
|
||||||
|
${_esc(pd.unit_id)}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">captured ${_fmtDateTime(pd.captured_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${coords}</div>
|
||||||
|
${noteHtml}
|
||||||
|
${footerActions}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPdStatus(status) {
|
||||||
|
_pdState.currentStatus = status;
|
||||||
|
const tabs = { awaiting: 'pd-tab-awaiting', assigned: 'pd-tab-assigned', cancelled: 'pd-tab-cancelled' };
|
||||||
|
Object.entries(tabs).forEach(([k, id]) => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (k === status) {
|
||||||
|
btn.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
btn.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('bg-seismo-orange', 'text-white');
|
||||||
|
btn.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
loadPdList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Classify modal ──────────────────────────────────────────────────
|
||||||
|
async function openClassifyModal(pendingId) {
|
||||||
|
const pd = _pdState.rows.find(r => r.id === pendingId);
|
||||||
|
if (!pd) return;
|
||||||
|
_pdState.classifyingId = pendingId;
|
||||||
|
_pdState.classifyingPd = pd;
|
||||||
|
|
||||||
|
document.getElementById('classify-unit-label').textContent = pd.unit_id;
|
||||||
|
document.getElementById('classify-captured-at').textContent = _fmtDateTime(pd.captured_at);
|
||||||
|
document.getElementById('classify-notes').value = '';
|
||||||
|
document.getElementById('classify-error').classList.add('hidden');
|
||||||
|
document.getElementById('new-project-name').value = '';
|
||||||
|
document.getElementById('new-location-name').value = '';
|
||||||
|
|
||||||
|
// Coords hint for "use captured coords" checkbox.
|
||||||
|
const hint = document.getElementById('captured-coords-hint');
|
||||||
|
if (pd.coordinates) {
|
||||||
|
hint.textContent = `(${pd.coordinates})`;
|
||||||
|
document.getElementById('use-captured-coords').checked = true;
|
||||||
|
document.getElementById('use-captured-coords').disabled = false;
|
||||||
|
} else {
|
||||||
|
hint.textContent = '(no GPS in photo — uncheck unless you want a placeholder)';
|
||||||
|
document.getElementById('use-captured-coords').checked = false;
|
||||||
|
document.getElementById('use-captured-coords').disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClassifyMode('existing');
|
||||||
|
|
||||||
|
document.getElementById('classify-modal').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load projects if not cached.
|
||||||
|
if (!_pdState.projectsCache) {
|
||||||
|
await _loadProjects();
|
||||||
|
}
|
||||||
|
_populateProjectSelects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeClassifyModal() {
|
||||||
|
document.getElementById('classify-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadProjects() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/projects/list');
|
||||||
|
const data = r.ok ? await r.json() : { projects: [] };
|
||||||
|
// Endpoint shape varies; tolerate either { projects: [...] } or a flat array.
|
||||||
|
_pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
} catch (e) {
|
||||||
|
_pdState.projectsCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _populateProjectSelects() {
|
||||||
|
// Sort active projects first, then alphabetical.
|
||||||
|
const projs = (_pdState.projectsCache || []).slice().sort((a, b) => {
|
||||||
|
if ((a.status || '') !== (b.status || '')) {
|
||||||
|
if (a.status === 'active') return -1;
|
||||||
|
if (b.status === 'active') return 1;
|
||||||
|
}
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingSel = document.getElementById('existing-project-select');
|
||||||
|
existingSel.innerHTML = '<option value="">— Pick a project —</option>' + projs.map(p =>
|
||||||
|
`<option value="${_esc(p.id)}">${_esc(p.name)}${p.status && p.status !== 'active' ? ` (${p.status})` : ''}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const newSel = document.getElementById('new-project-select');
|
||||||
|
newSel.innerHTML = '<option value="">— Create new project —</option>' + projs.map(p =>
|
||||||
|
`<option value="${_esc(p.id)}">${_esc(p.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onExistingProjectChange() {
|
||||||
|
const projectId = document.getElementById('existing-project-select').value;
|
||||||
|
const locSel = document.getElementById('existing-location-select');
|
||||||
|
if (!projectId) {
|
||||||
|
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locSel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations-json?location_type=vibration`);
|
||||||
|
const locs = await r.json();
|
||||||
|
if (!Array.isArray(locs) || locs.length === 0) {
|
||||||
|
locSel.innerHTML = '<option value="">(no locations — use Create new location instead)</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locSel.innerHTML = '<option value="">— Pick a location —</option>' + locs.map(l =>
|
||||||
|
`<option value="${_esc(l.id)}">${_esc(l.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
} catch (e) {
|
||||||
|
locSel.innerHTML = `<option value="">Load failed: ${_esc(e.message)}</option>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setClassifyMode(mode) {
|
||||||
|
_pdState.classifyMode = mode;
|
||||||
|
const ex = document.getElementById('mode-existing');
|
||||||
|
const nw = document.getElementById('mode-new');
|
||||||
|
document.getElementById('classify-existing-pane').classList.toggle('hidden', mode !== 'existing');
|
||||||
|
document.getElementById('classify-new-pane').classList.toggle('hidden', mode !== 'new');
|
||||||
|
const activeCls = ['bg-seismo-orange', 'text-white'];
|
||||||
|
const dormantCls = ['bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600'];
|
||||||
|
if (mode === 'existing') {
|
||||||
|
ex.classList.add(...activeCls); ex.classList.remove(...dormantCls);
|
||||||
|
nw.classList.remove(...activeCls); nw.classList.add(...dormantCls);
|
||||||
|
} else {
|
||||||
|
nw.classList.add(...activeCls); nw.classList.remove(...dormantCls);
|
||||||
|
ex.classList.remove(...activeCls); ex.classList.add(...dormantCls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNewProjectMode() {
|
||||||
|
const sel = document.getElementById('new-project-select');
|
||||||
|
const wrap = document.getElementById('new-project-name-wrap');
|
||||||
|
wrap.classList.toggle('hidden', !!sel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitClassify() {
|
||||||
|
const btn = document.getElementById('classify-submit');
|
||||||
|
const errEl = document.getElementById('classify-error');
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
btn.disabled = true; btn.textContent = 'Classifying…';
|
||||||
|
|
||||||
|
const body = { notes: document.getElementById('classify-notes').value.trim() };
|
||||||
|
|
||||||
|
if (_pdState.classifyMode === 'existing') {
|
||||||
|
const locId = document.getElementById('existing-location-select').value;
|
||||||
|
if (!locId) {
|
||||||
|
errEl.textContent = 'Pick a location.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.location_id = locId;
|
||||||
|
} else {
|
||||||
|
const existingProj = document.getElementById('new-project-select').value;
|
||||||
|
const newName = document.getElementById('new-project-name').value.trim();
|
||||||
|
const locName = document.getElementById('new-location-name').value.trim();
|
||||||
|
if (!locName) {
|
||||||
|
errEl.textContent = 'Location name required.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingProj) {
|
||||||
|
body.project_id = existingProj;
|
||||||
|
} else {
|
||||||
|
if (!newName) {
|
||||||
|
errEl.textContent = 'Project name (or pick an existing project) required.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.project_name = newName;
|
||||||
|
body.project_type_id = 'vibration_monitoring';
|
||||||
|
}
|
||||||
|
body.location_name = locName;
|
||||||
|
body.use_captured_coords = document.getElementById('use-captured-coords').checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/pending/${_pdState.classifyingId}/promote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
closeClassifyModal();
|
||||||
|
loadPdList();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false; btn.textContent = 'Classify';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPending(pendingId) {
|
||||||
|
const reason = prompt('Cancel this capture?\n\nOptional reason:');
|
||||||
|
if (reason === null) return; // user hit Cancel on the prompt
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/pending/${pendingId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
loadPdList();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Cancel failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off the initial load.
|
||||||
|
loadPdList();
|
||||||
|
// Refresh awaiting count every 30s for the badge.
|
||||||
|
setInterval(() => {
|
||||||
|
if (_pdState.currentStatus === 'awaiting') loadPdList();
|
||||||
|
}, 30000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Unit Swap</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Swap a vibration unit (and modem) at a monitoring location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/tools" class="text-xs text-seismo-orange hover:text-seismo-burgundy whitespace-nowrap">← Tools</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="flex items-center justify-between mb-4 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
<div id="swap-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-[10px]">1</span>
|
||||||
|
Project
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-2" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">2</span>
|
||||||
|
Location
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-3" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">3</span>
|
||||||
|
Unit
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-4" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">4</span>
|
||||||
|
Modem
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-5" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">5</span>
|
||||||
|
Confirm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Project picker -->
|
||||||
|
<div id="swap-step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which project?</span>
|
||||||
|
<input id="swap-project-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter by number, client, or name…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</label>
|
||||||
|
<div id="swap-project-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Location picker -->
|
||||||
|
<div id="swap-step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Project</p>
|
||||||
|
<p class="font-semibold text-seismo-orange truncate" id="swap-project-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Which location?</p>
|
||||||
|
<div id="swap-location-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: New unit picker -->
|
||||||
|
<div id="swap-step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Swapping at</p>
|
||||||
|
<p class="font-semibold text-seismo-orange truncate" id="swap-location-label">—</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Out: <span id="swap-old-unit-label" class="font-mono">—</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(2)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Incoming unit</span>
|
||||||
|
<input id="swap-unit-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter by serial…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only seismographs without an active assignment.</p>
|
||||||
|
</label>
|
||||||
|
<div id="swap-unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Modem decision -->
|
||||||
|
<div id="swap-step-4" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Incoming</p>
|
||||||
|
<p class="font-mono font-semibold text-seismo-orange" id="swap-new-unit-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(3)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" id="swap-modem-question">Modem?</p>
|
||||||
|
<div id="swap-modem-choice-list" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="swap-modem-picker-wrap" class="hidden">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Pick a modem</span>
|
||||||
|
<input id="swap-modem-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter modems…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</label>
|
||||||
|
<div id="swap-modem-list" class="max-h-60 overflow-y-auto space-y-1.5 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="swap-modem-next"
|
||||||
|
onclick="swapAfterModem()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5: Review + confirm -->
|
||||||
|
<div id="swap-step-5" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Review the swap</h3>
|
||||||
|
|
||||||
|
<dl class="text-sm space-y-2">
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Project</dt>
|
||||||
|
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-project">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||||
|
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-location">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Unit out</dt>
|
||||||
|
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-unit">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Unit in</dt>
|
||||||
|
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-unit">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Modem out</dt>
|
||||||
|
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-modem">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Modem in</dt>
|
||||||
|
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-modem">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes (optional)</span>
|
||||||
|
<textarea id="swap-notes" rows="2"
|
||||||
|
placeholder="Reason for swap, anything to remember…"
|
||||||
|
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="swap-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<button id="swap-confirm-btn"
|
||||||
|
onclick="swapConfirm()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
|
||||||
|
Confirm swap
|
||||||
|
</button>
|
||||||
|
<button onclick="swapGoToStep(4)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 6: Success + optional photo -->
|
||||||
|
<div id="swap-step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-14 h-14 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-3">Swap complete</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" id="swap-done-summary">—</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Add a photo of the new install?</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Optional. EXIF GPS will populate the unit's coordinates.</p>
|
||||||
|
<input id="swap-photo-input" type="file" accept="image/*" capture="environment"
|
||||||
|
onchange="swapOnPhotoPicked(event)"
|
||||||
|
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
|
||||||
|
<div id="swap-photo-preview-wrap" class="hidden mt-3">
|
||||||
|
<img id="swap-photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="">
|
||||||
|
</div>
|
||||||
|
<div id="swap-photo-error" class="hidden text-sm text-red-600 mt-2"></div>
|
||||||
|
<div id="swap-photo-status" class="hidden text-sm text-green-600 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button id="swap-photo-upload-btn"
|
||||||
|
onclick="swapUploadPhoto()"
|
||||||
|
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Upload photo
|
||||||
|
</button>
|
||||||
|
<button onclick="swapReset()"
|
||||||
|
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
||||||
|
Done — another swap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _swap = {
|
||||||
|
step: 1,
|
||||||
|
project: null, // { id, display, ... }
|
||||||
|
location: null, // { id, name, unit, modem }
|
||||||
|
new_unit: null, // { id, ... }
|
||||||
|
modem_action: null, // 'keep' | 'swap' | 'remove' | 'add' | 'none'
|
||||||
|
new_modem: null, // { id, ... }
|
||||||
|
all_projects: [],
|
||||||
|
all_units: [],
|
||||||
|
all_modems: [],
|
||||||
|
swap_result: null,
|
||||||
|
photo_file: null,
|
||||||
|
photo_preview_url: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _badge(deployed) {
|
||||||
|
return deployed
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">deployed</span>'
|
||||||
|
: '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">benched</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapGoToStep(n) {
|
||||||
|
_swap.step = n;
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const el = document.getElementById('swap-step-' + i);
|
||||||
|
if (el) el.classList.toggle('hidden', i !== n);
|
||||||
|
const pill = document.getElementById('swap-pill-' + i);
|
||||||
|
if (!pill) continue;
|
||||||
|
const dot = pill.querySelector('span');
|
||||||
|
if (i === n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-seismo-orange', 'font-medium');
|
||||||
|
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
dot.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
} else if (i < n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-green-600', 'dark:text-green-400');
|
||||||
|
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'bg-seismo-orange', 'text-white');
|
||||||
|
dot.classList.add('bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
|
||||||
|
} else {
|
||||||
|
pill.classList.add('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.remove('text-seismo-orange', 'font-medium', 'text-green-600', 'dark:text-green-400');
|
||||||
|
dot.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
dot.classList.remove('bg-seismo-orange', 'text-white', 'bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('swap-step-done').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: project picker ──────────────────────────────────────────
|
||||||
|
async function _swapLoadProjects() {
|
||||||
|
const list = document.getElementById('swap-project-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/projects/search-json?limit=50');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
_swap.all_projects = await r.json();
|
||||||
|
_swapRenderProjects();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderProjects() {
|
||||||
|
const q = document.getElementById('swap-project-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-project-list');
|
||||||
|
let items = _swap.all_projects;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(p => {
|
||||||
|
const hay = [(p.project_number||''), (p.client_name||''), (p.name||''), (p.display||'')]
|
||||||
|
.join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching projects.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(p => {
|
||||||
|
const num = p.project_number ? `<span class="font-mono text-xs text-gray-500 dark:text-gray-400">${_esc(p.project_number)}</span>` : '';
|
||||||
|
const client = p.client_name ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(p.client_name)}</div>` : '';
|
||||||
|
return `<button onclick='swapPickProject(${JSON.stringify(p.id)})'
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(p.name)}</span>
|
||||||
|
${num}
|
||||||
|
</div>
|
||||||
|
${client}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickProject(projectId) {
|
||||||
|
const p = _swap.all_projects.find(x => x.id === projectId);
|
||||||
|
if (!p) return;
|
||||||
|
_swap.project = p;
|
||||||
|
document.getElementById('swap-project-label').textContent = p.name;
|
||||||
|
_swapLoadLocations();
|
||||||
|
swapGoToStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: location picker ─────────────────────────────────────────
|
||||||
|
async function _swapLoadLocations() {
|
||||||
|
const list = document.getElementById('swap-location-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/locations-with-assignments?location_type=vibration`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
_swapRenderLocations(data);
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderLocations(locations) {
|
||||||
|
const list = document.getElementById('swap-location-list');
|
||||||
|
if (!locations || locations.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No vibration locations in this project.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = locations.map(loc => {
|
||||||
|
const unit = loc.unit;
|
||||||
|
const modem = loc.modem;
|
||||||
|
const unitLine = unit
|
||||||
|
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
|
||||||
|
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
|
||||||
|
const modemLine = modem
|
||||||
|
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono mt-0.5 flex items-center gap-2">
|
||||||
|
<span>+ modem ${_esc(modem.id)}</span>
|
||||||
|
${_badge(modem.deployed)}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
// Pass index for cleaner attribute escaping
|
||||||
|
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(loc.name)}</span>
|
||||||
|
</div>
|
||||||
|
${unitLine}
|
||||||
|
${modemLine}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
_swap._locations_cache = locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapPickLocationByIdx(idxStr) {
|
||||||
|
const idx = parseInt(idxStr, 10);
|
||||||
|
const loc = _swap._locations_cache[idx];
|
||||||
|
if (!loc) return;
|
||||||
|
_swap.location = loc;
|
||||||
|
document.getElementById('swap-location-label').textContent = loc.name;
|
||||||
|
document.getElementById('swap-old-unit-label').textContent = loc.unit ? loc.unit.id : '(empty)';
|
||||||
|
_swapLoadUnits();
|
||||||
|
swapGoToStep(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: incoming unit picker ────────────────────────────────────
|
||||||
|
async function _swapLoadUnits() {
|
||||||
|
const list = document.getElementById('swap-unit-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration&include_benched=true`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
_swap.all_units = await r.json();
|
||||||
|
_swapRenderUnits();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderUnits() {
|
||||||
|
const q = document.getElementById('swap-unit-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-unit-list');
|
||||||
|
let items = _swap.all_units;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(u => {
|
||||||
|
const hay = [(u.id||''), (u.model||''), (u.location||'')].join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No available seismographs.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(u => {
|
||||||
|
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
|
||||||
|
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
|
||||||
|
const badge = _badge(u.deployed);
|
||||||
|
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
|
${model}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${loc}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickUnit(unitId) {
|
||||||
|
const u = _swap.all_units.find(x => x.id === unitId);
|
||||||
|
if (!u) return;
|
||||||
|
_swap.new_unit = u;
|
||||||
|
document.getElementById('swap-new-unit-label').textContent = u.id;
|
||||||
|
_swapInitModemStep();
|
||||||
|
swapGoToStep(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: modem decision ──────────────────────────────────────────
|
||||||
|
function _swapInitModemStep() {
|
||||||
|
_swap.modem_action = null;
|
||||||
|
_swap.new_modem = null;
|
||||||
|
document.getElementById('swap-modem-picker-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('swap-modem-next').disabled = true;
|
||||||
|
|
||||||
|
const current = _swap.location && _swap.location.modem;
|
||||||
|
const choices = document.getElementById('swap-modem-choice-list');
|
||||||
|
const question = document.getElementById('swap-modem-question');
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
question.textContent = 'Modem at this location';
|
||||||
|
choices.innerHTML = `
|
||||||
|
<button data-action="keep" onclick="swapPickModemAction('keep')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
|
||||||
|
${_badge(current.deployed)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="swap" onclick="swapPickModemAction('swap')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Swap modem too</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Pick a different unassigned modem.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="remove" onclick="swapPickModemAction('remove')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Incoming unit goes solo (no cellular).</div>
|
||||||
|
</button>`;
|
||||||
|
} else {
|
||||||
|
question.textContent = 'No modem at this location currently.';
|
||||||
|
choices.innerHTML = `
|
||||||
|
<button data-action="none" onclick="swapPickModemAction('none')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Standalone / manual download.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="add" onclick="swapPickModemAction('add')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Add a modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Pair an unassigned modem with the incoming unit.</div>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickModemAction(action) {
|
||||||
|
_swap.modem_action = action;
|
||||||
|
_swap.new_modem = null;
|
||||||
|
// Highlight the picked choice; dim the others.
|
||||||
|
document.querySelectorAll('.swap-modem-choice').forEach(btn => {
|
||||||
|
if (btn.dataset.action === action) {
|
||||||
|
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickerWrap = document.getElementById('swap-modem-picker-wrap');
|
||||||
|
const nextBtn = document.getElementById('swap-modem-next');
|
||||||
|
|
||||||
|
if (action === 'swap' || action === 'add') {
|
||||||
|
pickerWrap.classList.remove('hidden');
|
||||||
|
nextBtn.disabled = true;
|
||||||
|
_swapLoadModems();
|
||||||
|
} else {
|
||||||
|
pickerWrap.classList.add('hidden');
|
||||||
|
nextBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _swapLoadModems() {
|
||||||
|
const list = document.getElementById('swap-modem-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems?include_benched=true`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
|
||||||
|
let modems = await r.json();
|
||||||
|
const currentModemId = _swap.location && _swap.location.modem ? _swap.location.modem.id : null;
|
||||||
|
if (currentModemId) modems = modems.filter(m => m.id !== currentModemId);
|
||||||
|
_swap.all_modems = modems;
|
||||||
|
_swapRenderModems();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderModems() {
|
||||||
|
const q = document.getElementById('swap-modem-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-modem-list');
|
||||||
|
let items = _swap.all_modems;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(m => {
|
||||||
|
const hay = [(m.id||''), (m.hardware_model||''), (m.ip_address||'')].join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No modems available.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(m => {
|
||||||
|
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
|
||||||
|
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
|
||||||
|
const badge = _badge(m.deployed);
|
||||||
|
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
|
||||||
|
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
|
||||||
|
data-modem-id="${_esc(m.id)}">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
|
${hw}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${ip}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickModem(modemId) {
|
||||||
|
const m = _swap.all_modems.find(x => x.id === modemId);
|
||||||
|
if (!m) return;
|
||||||
|
_swap.new_modem = m;
|
||||||
|
document.querySelectorAll('.swap-modem-pick').forEach(btn => {
|
||||||
|
if (btn.dataset.modemId === modemId) {
|
||||||
|
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('swap-modem-next').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapAfterModem() {
|
||||||
|
// Populate review screen
|
||||||
|
document.getElementById('swap-review-project').textContent = _swap.project.name;
|
||||||
|
document.getElementById('swap-review-location').textContent = _swap.location.name;
|
||||||
|
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
|
||||||
|
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
|
||||||
|
|
||||||
|
const oldModemEl = document.getElementById('swap-review-old-modem');
|
||||||
|
if (_swap.location.modem) {
|
||||||
|
oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`;
|
||||||
|
} else {
|
||||||
|
oldModemEl.textContent = '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newModemEl = document.getElementById('swap-review-new-modem');
|
||||||
|
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} <span class="text-xs text-gray-500">(kept)</span> ${_badge(_swap.location.modem.deployed)}`;
|
||||||
|
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`;
|
||||||
|
} else {
|
||||||
|
newModemEl.textContent = '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
swapGoToStep(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: confirm ─────────────────────────────────────────────────
|
||||||
|
async function swapConfirm() {
|
||||||
|
const btn = document.getElementById('swap-confirm-btn');
|
||||||
|
const err = document.getElementById('swap-error');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
|
||||||
|
// Determine modem_id to send:
|
||||||
|
// 'keep' → current modem id (re-pair to new unit)
|
||||||
|
// 'swap' → newly-picked modem id
|
||||||
|
// 'add' → newly-picked modem id
|
||||||
|
// 'remove' → omit (endpoint clears new unit's pairing)
|
||||||
|
// 'none' → omit
|
||||||
|
let modemIdToSend = null;
|
||||||
|
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
||||||
|
modemIdToSend = _swap.location.modem.id;
|
||||||
|
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
||||||
|
modemIdToSend = _swap.new_modem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Swapping…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('unit_id', _swap.new_unit.id);
|
||||||
|
if (modemIdToSend) fd.append('modem_id', modemIdToSend);
|
||||||
|
const notes = document.getElementById('swap-notes').value.trim();
|
||||||
|
if (notes) fd.append('notes', notes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/projects/${encodeURIComponent(_swap.project.id)}/locations/${encodeURIComponent(_swap.location.id)}/swap`;
|
||||||
|
const r = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
_swap.swap_result = await r.json();
|
||||||
|
_swapShowDone();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Confirm swap';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapShowDone() {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
document.getElementById('swap-step-' + i).classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('swap-step-done').classList.remove('hidden');
|
||||||
|
const summary = `${_swap.new_unit.id} is now at ${_swap.location.name}` +
|
||||||
|
(_swap.location.unit ? ` (replacing ${_swap.location.unit.id}).` : '.');
|
||||||
|
document.getElementById('swap-done-summary').textContent = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo upload (optional) ─────────────────────────────────────────
|
||||||
|
function swapOnPhotoPicked(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
_swap.photo_file = file;
|
||||||
|
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
|
||||||
|
_swap.photo_preview_url = URL.createObjectURL(file);
|
||||||
|
document.getElementById('swap-photo-preview').src = _swap.photo_preview_url;
|
||||||
|
document.getElementById('swap-photo-preview-wrap').classList.remove('hidden');
|
||||||
|
document.getElementById('swap-photo-upload-btn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapUploadPhoto() {
|
||||||
|
if (!_swap.photo_file) return;
|
||||||
|
const btn = document.getElementById('swap-photo-upload-btn');
|
||||||
|
const err = document.getElementById('swap-photo-error');
|
||||||
|
const ok = document.getElementById('swap-photo-status');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
ok.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', _swap.photo_file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/unit/${encodeURIComponent(_swap.new_unit.id)}/upload-photo?auto_populate_coords=true`;
|
||||||
|
const r = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
const coords = data && data.metadata && data.metadata.coordinates;
|
||||||
|
ok.textContent = coords ? `Uploaded. GPS: ${coords}` : 'Uploaded (no GPS in EXIF).';
|
||||||
|
ok.classList.remove('hidden');
|
||||||
|
btn.textContent = 'Uploaded';
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Upload photo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapReset() {
|
||||||
|
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
|
||||||
|
Object.assign(_swap, {
|
||||||
|
project: null, location: null, new_unit: null,
|
||||||
|
modem_action: null, new_modem: null,
|
||||||
|
all_projects: [], all_units: [], all_modems: [],
|
||||||
|
swap_result: null, photo_file: null, photo_preview_url: null,
|
||||||
|
});
|
||||||
|
document.getElementById('swap-project-search').value = '';
|
||||||
|
document.getElementById('swap-unit-search').value = '';
|
||||||
|
document.getElementById('swap-modem-search').value = '';
|
||||||
|
document.getElementById('swap-notes').value = '';
|
||||||
|
document.getElementById('swap-photo-input').value = '';
|
||||||
|
document.getElementById('swap-photo-preview-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('swap-photo-error').classList.add('hidden');
|
||||||
|
document.getElementById('swap-photo-status').classList.add('hidden');
|
||||||
|
document.getElementById('swap-error').classList.add('hidden');
|
||||||
|
const confirmBtn = document.getElementById('swap-confirm-btn');
|
||||||
|
confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm swap';
|
||||||
|
const photoBtn = document.getElementById('swap-photo-upload-btn');
|
||||||
|
photoBtn.disabled = true; photoBtn.textContent = 'Upload photo';
|
||||||
|
swapGoToStep(1);
|
||||||
|
_swapLoadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up live filtering inputs.
|
||||||
|
document.getElementById('swap-project-search').addEventListener('input', _swapRenderProjects);
|
||||||
|
document.getElementById('swap-unit-search').addEventListener('input', _swapRenderUnits);
|
||||||
|
document.getElementById('swap-modem-search').addEventListener('input', _swapRenderModems);
|
||||||
|
|
||||||
|
// Kick off.
|
||||||
|
_swapLoadProjects();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SFM Event DB Manager - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<a href="/settings#developer" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Developer Tools</a>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">SFM Event DB Manager</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Browse, flag, and delete triggered events from SFM's events table. Destructive actions also clean up on-disk waveform / sidecar / pickle / hdf5 files.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadEvents()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning banner -->
|
||||||
|
<div class="rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-2xl">⚠️</span>
|
||||||
|
<div class="text-sm text-red-900 dark:text-red-200">
|
||||||
|
<strong class="font-semibold">Destructive operations.</strong>
|
||||||
|
Delete actions remove rows from SFM's events table AND delete associated waveform files on disk. Both are permanent — there is no undo. Use filters carefully, dry-run first, and verify the match count before confirming.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-5 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Filters</h2>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Serial</label>
|
||||||
|
<input type="text" id="f-serial" placeholder="e.g. BE9558"
|
||||||
|
onkeydown="if (event.key === 'Enter') loadEvents()"
|
||||||
|
class="px-3 py-1.5 text-sm font-mono w-40 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||||
|
<input type="datetime-local" id="f-from"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||||
|
<input type="datetime-local" id="f-to"
|
||||||
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
|
||||||
|
<select id="f-ft" class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="false">Real Only</option>
|
||||||
|
<option value="true">FT Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
|
||||||
|
<select id="f-limit" class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="500" selected>500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
<option value="5000">5000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadEvents()"
|
||||||
|
class="px-4 py-1.5 text-sm font-medium rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button onclick="clearFilters()"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk actions -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span id="bulk-selected" class="text-sm text-gray-600 dark:text-gray-400">0 selected</span>
|
||||||
|
|
||||||
|
<button id="bulk-delete-selected" onclick="deleteSelected()" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
🗑 Delete selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="deleteByFilter()"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-red-400 dark:border-red-600 bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 hover:bg-red-100 dark:hover:bg-red-900/50 font-medium">
|
||||||
|
🗑 Delete ALL matching current filter…
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="border-l border-gray-300 dark:border-gray-600 h-6"></div>
|
||||||
|
|
||||||
|
<button id="bulk-flag-ft" onclick="flagSelected(true)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
🚩 Flag as FT
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="bulk-clear-ft" onclick="flagSelected(false)" disabled
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
✓ Clear FT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700" id="results-summary">
|
||||||
|
Apply filters above to load events.
|
||||||
|
</div>
|
||||||
|
<div id="results-table" class="overflow-x-auto">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No query run yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SFM_PROXY = '/api/sfm';
|
||||||
|
const _selected = new Set();
|
||||||
|
let _events = [];
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
function _fmtPpv(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); }
|
||||||
|
|
||||||
|
function _refreshBulkButtons() {
|
||||||
|
const n = _selected.size;
|
||||||
|
document.getElementById('bulk-selected').textContent = `${n} selected`;
|
||||||
|
document.getElementById('bulk-delete-selected').disabled = (n === 0);
|
||||||
|
document.getElementById('bulk-flag-ft').disabled = (n === 0);
|
||||||
|
document.getElementById('bulk-clear-ft').disabled = (n === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _currentFilter() {
|
||||||
|
const f = {};
|
||||||
|
const serial = document.getElementById('f-serial').value.trim();
|
||||||
|
const from = document.getElementById('f-from').value;
|
||||||
|
const to = document.getElementById('f-to').value;
|
||||||
|
const ft = document.getElementById('f-ft').value;
|
||||||
|
if (serial) f.serial = serial;
|
||||||
|
if (from) f.from_dt = from;
|
||||||
|
if (to) f.to_dt = to;
|
||||||
|
if (ft === 'true') f.false_trigger = true;
|
||||||
|
if (ft === 'false') f.false_trigger = false;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
document.getElementById('f-serial').value = '';
|
||||||
|
document.getElementById('f-from').value = '';
|
||||||
|
document.getElementById('f-to').value = '';
|
||||||
|
document.getElementById('f-ft').value = '';
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const f = _currentFilter();
|
||||||
|
const limit = document.getElementById('f-limit').value || '500';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (f.serial) params.set('serial', f.serial);
|
||||||
|
if (f.from_dt) params.set('from_dt', f.from_dt);
|
||||||
|
if (f.to_dt) params.set('to_dt', f.to_dt);
|
||||||
|
if (f.false_trigger !== undefined) params.set('false_trigger', String(f.false_trigger));
|
||||||
|
params.set('limit', limit);
|
||||||
|
|
||||||
|
const container = document.getElementById('results-table');
|
||||||
|
const summary = document.getElementById('results-summary');
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events?${params.toString()}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: HTTP ${resp.status}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
_events = data.events || [];
|
||||||
|
summary.textContent = `Showing ${_events.length} event${_events.length === 1 ? '' : 's'} (limit ${limit})`;
|
||||||
|
renderTable();
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: ${_esc(err.message || err)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const container = document.getElementById('results-table');
|
||||||
|
if (_events.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">No events match the current filter.</div>';
|
||||||
|
_refreshBulkButtons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = _events.map(ev => {
|
||||||
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||||
|
const ft = ev.false_trigger
|
||||||
|
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
|
||||||
|
: '';
|
||||||
|
const checked = _selected.has(ev.id) ? 'checked' : '';
|
||||||
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
|
||||||
|
onclick="showEventDetail('${_esc(ev.id)}')">
|
||||||
|
<td class="px-3 py-2" onclick="event.stopPropagation()"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.vert_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.long_ppv)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-right font-semibold">${_fmtPpv(ev.peak_vector_sum)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm">${ft}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono text-gray-500 dark:text-gray-400 truncate" style="max-width:240px;" title="${_esc(ev.id)}">${_esc(ev.id).slice(0, 8)}…</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2"><input type="checkbox" id="check-all" onchange="toggleAllRows(this.checked)"></th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Serial</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Timestamp</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Tran</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Vert</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Long</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">PVS</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Flags</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowCheck(input) {
|
||||||
|
const id = input.getAttribute('data-event-id');
|
||||||
|
if (input.checked) _selected.add(id);
|
||||||
|
else {
|
||||||
|
_selected.delete(id);
|
||||||
|
const master = document.getElementById('check-all');
|
||||||
|
if (master) master.checked = false;
|
||||||
|
}
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllRows(checked) {
|
||||||
|
document.querySelectorAll('.row-check').forEach(cb => {
|
||||||
|
const id = cb.getAttribute('data-event-id');
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) _selected.add(id);
|
||||||
|
else _selected.delete(id);
|
||||||
|
});
|
||||||
|
_refreshBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (_selected.size === 0) return;
|
||||||
|
const ids = Array.from(_selected);
|
||||||
|
if (!confirm(`PERMANENTLY delete ${ids.length} event${ids.length === 1 ? '' : 's'} from the SFM DB?\n\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids, confirm: true }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Delete failed: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteByFilter() {
|
||||||
|
const f = _currentFilter();
|
||||||
|
if (Object.keys(f).length === 0) {
|
||||||
|
if (!confirm('No filters set — this would attempt to delete EVERY event in the SFM DB.\n\nAre you absolutely sure? You probably want a serial filter at minimum.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dry-run first
|
||||||
|
let matched = 0;
|
||||||
|
let sample_serials = [];
|
||||||
|
try {
|
||||||
|
const dry = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(f),
|
||||||
|
});
|
||||||
|
const dryData = await dry.json();
|
||||||
|
if (!dry.ok) {
|
||||||
|
alert(`Dry-run failed: HTTP ${dry.status}\n${JSON.stringify(dryData)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matched = dryData.matched || 0;
|
||||||
|
sample_serials = dryData.sample_serials || [];
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Dry-run failed: ${err.message || err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matched === 0) {
|
||||||
|
alert('No events match the current filter — nothing to delete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLines = Object.entries(f).map(([k, v]) => ` ${k} = ${v}`).join('\n') || ' (none)';
|
||||||
|
const serialList = sample_serials.length ? ` serials: ${sample_serials.join(', ')}${sample_serials.length === 5 ? ' (and possibly more)' : ''}\n` : '';
|
||||||
|
if (!confirm(`PERMANENTLY delete ${matched} event${matched === 1 ? '' : 's'}?\n\nFilter:\n${filterLines}\n${serialList}\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...f, confirm: true }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.status === 'too_many') {
|
||||||
|
alert(`Refused: ${data.hint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Delete failed: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flagSelected(value) {
|
||||||
|
if (_selected.size === 0) return;
|
||||||
|
const ids = Array.from(_selected);
|
||||||
|
const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on';
|
||||||
|
if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) return;
|
||||||
|
let ok = 0, failed = 0, cursor = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < ids.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
const id = ids[i];
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${SFM_PROXY}/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`,
|
||||||
|
{ method: 'PATCH' });
|
||||||
|
if (r.ok) ok++; else failed++;
|
||||||
|
} catch (_) { failed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: 8 }, worker));
|
||||||
|
if (failed) alert(`${ok} updated, ${failed} failed.`);
|
||||||
|
_selected.clear();
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial empty state — let the user choose to load.
|
||||||
|
|
||||||
|
// Refresh the events table when the modal's review form saves — keeps
|
||||||
|
// the FT badge in sync without a full page reload.
|
||||||
|
window.addEventListener('sfm-event-review-saved', () => {
|
||||||
|
if (_events.length) loadEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||||
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
|
<script src="/static/event-modal.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -42,6 +42,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Monitoring (keepalive) -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-1">Live Monitoring (keepalive)</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Keepalive runs the 1 Hz DOD feed 24/7 (even with no viewer), which powers the live-chart
|
||||||
|
trail and continuous threshold alerts. Toggling persists and survives restarts.
|
||||||
|
</p>
|
||||||
|
<div id="monitor-list" class="text-sm">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Raw API tester -->
|
<!-- Raw API tester -->
|
||||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
||||||
@@ -132,7 +144,60 @@ async function sendRaw() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMonitors() {
|
||||||
|
const el = document.getElementById('monitor-list');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/slmm/roster');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const d = await r.json();
|
||||||
|
const devices = d.devices || [];
|
||||||
|
if (!devices.length) {
|
||||||
|
el.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No devices configured.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = devices.map(dev => {
|
||||||
|
const on = !!dev.monitor_enabled;
|
||||||
|
const reach = dev.status ? dev.status.is_reachable : null;
|
||||||
|
const reachDot = reach === false
|
||||||
|
? '<span class="w-2 h-2 rounded-full bg-red-500 inline-block" title="unreachable"></span>'
|
||||||
|
: '<span class="w-2 h-2 rounded-full bg-green-500 inline-block" title="reachable"></span>';
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-100 dark:border-gray-700 py-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${reachDot}
|
||||||
|
<span class="font-mono text-gray-900 dark:text-white">${_esc(dev.unit_id)}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(dev.host)}:${_esc(dev.tcp_port)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs font-medium px-2 py-0.5 rounded ${on
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400'}">${on ? '24/7 ON' : 'OFF'}</span>
|
||||||
|
<button onclick="toggleMonitor('${_esc(dev.unit_id)}', ${!on})"
|
||||||
|
class="px-3 py-1 text-xs rounded text-white ${on
|
||||||
|
? 'bg-red-600 hover:bg-red-700' : 'bg-seismo-orange hover:bg-orange-600'}">
|
||||||
|
${on ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="text-red-600 dark:text-red-400">Failed to load devices: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMonitor(unitId, enable) {
|
||||||
|
const action = enable ? 'start' : 'stop';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/monitor/${action}`, { method: 'POST' });
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
await loadMonitors();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Toggle failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadSlmmOverview();
|
loadSlmmOverview();
|
||||||
setInterval(loadSlmmOverview, 30000);
|
loadMonitors();
|
||||||
|
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+13
-8
@@ -150,6 +150,7 @@
|
|||||||
(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'
|
||||||
@@ -210,7 +211,11 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) -->
|
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
||||||
|
Deploy is here because the whole point of the workflow is "I'm
|
||||||
|
on site, capture this install in 90s before I leave." Devices,
|
||||||
|
Settings, Projects, Job Planner reachable via the hamburger
|
||||||
|
Menu (slot 1) which opens the full sidebar drawer. -->
|
||||||
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
<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">
|
||||||
@@ -225,18 +230,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
<button class="bottom-nav-btn" data-href="/deploy" onclick="window.location.href='/deploy'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Devices</span>
|
<span>Deploy</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>Settings</span>
|
<span>Events</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+145
-52
@@ -10,17 +10,75 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-8 flex justify-between items-center">
|
<div class="mb-8 flex justify-between items-center gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="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"
|
||||||
hx-trigger="load, every 10s"
|
hx-trigger="load, every 10s"
|
||||||
@@ -92,46 +150,55 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 card-content" id="fleet-summary-content">
|
<div class="space-y-4 card-content" id="fleet-summary-content">
|
||||||
<div class="flex justify-between items-center">
|
<!-- Seismographs -->
|
||||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
<div>
|
||||||
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
|
||||||
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
|
||||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
|
||||||
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
|
||||||
<div class="flex justify-between items-center mb-1">
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||||
</div>
|
</div>
|
||||||
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sound Level Meters -->
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||||
</div>
|
</div>
|
||||||
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
|
||||||
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
@@ -570,9 +637,14 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
fleetMarkers = [];
|
fleetMarkers = [];
|
||||||
|
|
||||||
// Get deployed units with coordinates that pass the filter
|
// Get deployed units with coordinates that pass the filter.
|
||||||
|
// Modems are not plotted — they inherit the paired device's location,
|
||||||
|
// which would just stack a duplicate marker on the same pin.
|
||||||
const deployedUnits = Object.entries(allUnits || {})
|
const deployedUnits = Object.entries(allUnits || {})
|
||||||
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
|
.filter(([_, u]) => u.deployed
|
||||||
|
&& u.coordinates
|
||||||
|
&& (u.device_type || 'seismograph') !== 'modem'
|
||||||
|
&& unitPassesFilter(u));
|
||||||
|
|
||||||
if (deployedUnits.length === 0) {
|
if (deployedUnits.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -614,10 +686,12 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
// Popup with device type
|
// Popup with device type
|
||||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
const deviceLabel = getDeviceTypeLabel(deviceType);
|
||||||
|
|
||||||
|
const locName = unit.location_name || '';
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<h3 class="font-bold text-lg">${id}</h3>
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
||||||
|
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
|
||||||
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||||
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
||||||
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
||||||
@@ -725,32 +799,51 @@ function updateDashboard(event) {
|
|||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Fleet summary numbers (always unfiltered) =====
|
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
|
||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
// Deployed = unit has an active UnitAssignment (location_id set by
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
// the snapshot helper). Benched = no active assignment.
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
// Retired, out-for-calibration, and roster-unknown units (emitters
|
||||||
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
// not in the roster) are excluded from totals.
|
||||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
const counts = {
|
||||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
seismograph: { total: 0, deployed: 0, benched: 0 },
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
sound_level_meter: { total: 0, deployed: 0, benched: 0 },
|
||||||
|
};
|
||||||
|
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
|
||||||
|
const unknownIds = new Set(Object.keys(data.unknown || {}));
|
||||||
|
|
||||||
// ===== Device type counts (always unfiltered) =====
|
Object.entries(data.units || {}).forEach(([uid, unit]) => {
|
||||||
let seismoCount = 0;
|
if (unit.retired || unit.out_for_calibration) return;
|
||||||
let slmCount = 0;
|
if (unknownIds.has(uid)) return;
|
||||||
let modemCount = 0;
|
const dt = unit.device_type || 'seismograph';
|
||||||
Object.values(data.units || {}).forEach(unit => {
|
const bucket = counts[dt];
|
||||||
if (unit.retired) return; // Don't count retired units
|
if (!bucket) return; // skip modems and anything else
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
|
||||||
if (deviceType === 'seismograph') {
|
bucket.total++;
|
||||||
seismoCount++;
|
if (unit.location_id) {
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
bucket.deployed++;
|
||||||
slmCount++;
|
} else {
|
||||||
} else if (deviceType === 'modem') {
|
bucket.benched++;
|
||||||
modemCount++;
|
}
|
||||||
|
|
||||||
|
// Status tally only for seismographs + SLMs that are actually
|
||||||
|
// deployed (assigned). Mirrors the per-device buckets so the
|
||||||
|
// sum matches.
|
||||||
|
if (unit.location_id) {
|
||||||
|
if (unit.status === 'OK') monitoredOk++;
|
||||||
|
else if (unit.status === 'Pending') monitoredPending++;
|
||||||
|
else if (unit.status === 'Missing') monitoredMissing++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('seismo-count').textContent = seismoCount;
|
|
||||||
document.getElementById('slm-count').textContent = slmCount;
|
document.getElementById('seismo-count').textContent = counts.seismograph.total;
|
||||||
|
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
|
||||||
|
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
|
||||||
|
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
|
||||||
|
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
|
||||||
|
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
|
||||||
|
document.getElementById('status-ok').textContent = monitoredOk;
|
||||||
|
document.getElementById('status-pending').textContent = monitoredPending;
|
||||||
|
document.getElementById('status-missing').textContent = monitoredMissing;
|
||||||
|
|
||||||
// ===== Apply filters and render map + alerts =====
|
// ===== Apply filters and render map + alerts =====
|
||||||
renderFilteredDashboard(data);
|
renderFilteredDashboard(data);
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Field Deploy - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Field Deploy</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Capture an install while you're still on site. Project + location can be picked later at a desk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="flex items-center justify-between mb-6 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div id="step-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-xs">1</span>
|
||||||
|
Unit
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
||||||
|
<div id="step-pill-2" class="flex items-center gap-1">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">2</span>
|
||||||
|
Photo
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
||||||
|
<div id="step-pill-3" class="flex items-center gap-1">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">3</span>
|
||||||
|
Confirm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Pick unit -->
|
||||||
|
<div id="step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which seismograph?</span>
|
||||||
|
<input id="unit-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Type a serial like BE12599…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||||
|
oninput="onUnitSearch()">
|
||||||
|
</label>
|
||||||
|
<div id="unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Photo -->
|
||||||
|
<div id="step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
|
||||||
|
<p class="font-mono font-semibold text-seismo-orange" id="step2-unit-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="goToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange">Change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Install photo</span>
|
||||||
|
<input id="photo-input" type="file" accept="image/*"
|
||||||
|
onchange="onPhotoPicked(event)"
|
||||||
|
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Take a new photo or pick a previously taken one. EXIF GPS is auto-extracted either way.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="photo-preview-wrap" class="hidden">
|
||||||
|
<img id="photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="Install photo preview">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Note + confirm -->
|
||||||
|
<div id="step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
|
||||||
|
<p class="font-mono font-semibold text-seismo-orange" id="step3-unit-label">—</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="step3-photo-wrap" class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||||
|
<img id="step3-photo" class="w-full" alt="Install photo">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="step3-coords-status" class="text-xs"></div>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Site memo (optional)</span>
|
||||||
|
<textarea id="note-input" rows="3"
|
||||||
|
placeholder="e.g. Carnegie Museum, north entrance loading dock"
|
||||||
|
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Helpful when classifying later. Free text — anything that helps you remember.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="capture-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<button id="capture-submit"
|
||||||
|
onclick="submitCapture()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
|
||||||
|
Capture
|
||||||
|
</button>
|
||||||
|
<button onclick="goToStep(2)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Retake photo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success -->
|
||||||
|
<div id="step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4 text-center">
|
||||||
|
<div class="w-16 h-16 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Captured</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<span id="done-unit-label" class="font-mono font-semibold text-seismo-orange"></span>
|
||||||
|
is now in the pending hopper.
|
||||||
|
You can classify it from <a href="/tools/pending-deployments" class="text-seismo-orange hover:text-seismo-navy underline">Tools → Pending Deployments</a> later.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-2" id="done-coords-label"></p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button onclick="resetWizard()"
|
||||||
|
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||||
|
Deploy another
|
||||||
|
</button>
|
||||||
|
<a href="/tools/pending-deployments"
|
||||||
|
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium inline-flex items-center justify-center">
|
||||||
|
View pending
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let _deployState = {
|
||||||
|
unit_id: null,
|
||||||
|
photo_file: null,
|
||||||
|
photo_preview_url: null,
|
||||||
|
captured: null, // server response from /capture
|
||||||
|
};
|
||||||
|
|
||||||
|
let _unitSearchDebounce = null;
|
||||||
|
async function onUnitSearch() {
|
||||||
|
if (_unitSearchDebounce) clearTimeout(_unitSearchDebounce);
|
||||||
|
_unitSearchDebounce = setTimeout(_fetchUnitList, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchUnitList() {
|
||||||
|
const q = document.getElementById('unit-search').value.trim();
|
||||||
|
const list = document.getElementById('unit-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Searching…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/deployments/seismograph-picker?q=${encodeURIComponent(q)}&limit=20`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
_renderUnitList(data.units || []);
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Search failed: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderUnitList(units) {
|
||||||
|
const list = document.getElementById('unit-list');
|
||||||
|
if (units.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching units.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = units.map(u => {
|
||||||
|
const noteHtml = u.note ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.note)}</div>` : '';
|
||||||
|
const pendingBadge = u.has_pending
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 ml-2">already pending</span>'
|
||||||
|
: '';
|
||||||
|
return `<button onclick="onPickUnit('${_esc(u.id)}')"
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
||||||
|
${pendingBadge}
|
||||||
|
</div>
|
||||||
|
${noteHtml}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickUnit(unitId) {
|
||||||
|
_deployState.unit_id = unitId;
|
||||||
|
document.getElementById('step2-unit-label').textContent = unitId;
|
||||||
|
document.getElementById('step3-unit-label').textContent = unitId;
|
||||||
|
goToStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPhotoPicked(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
_deployState.photo_file = file;
|
||||||
|
// Show preview.
|
||||||
|
if (_deployState.photo_preview_url) URL.revokeObjectURL(_deployState.photo_preview_url);
|
||||||
|
_deployState.photo_preview_url = URL.createObjectURL(file);
|
||||||
|
document.getElementById('photo-preview').src = _deployState.photo_preview_url;
|
||||||
|
document.getElementById('step3-photo').src = _deployState.photo_preview_url;
|
||||||
|
document.getElementById('photo-preview-wrap').classList.remove('hidden');
|
||||||
|
// Advance after a tiny delay so the user sees the preview.
|
||||||
|
setTimeout(() => goToStep(3), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep(n) {
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const el = document.getElementById('step-' + i);
|
||||||
|
if (el) el.classList.toggle('hidden', i !== n);
|
||||||
|
const pill = document.getElementById('step-pill-' + i);
|
||||||
|
if (pill) {
|
||||||
|
if (i === n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-seismo-orange', 'font-medium');
|
||||||
|
pill.querySelector('span').classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
pill.querySelector('span').classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
} else {
|
||||||
|
pill.classList.add('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.remove('text-seismo-orange', 'font-medium');
|
||||||
|
pill.querySelector('span').classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
pill.querySelector('span').classList.remove('bg-seismo-orange', 'text-white');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('step-done').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCapture() {
|
||||||
|
const btn = document.getElementById('capture-submit');
|
||||||
|
const err = document.getElementById('capture-error');
|
||||||
|
const note = document.getElementById('note-input').value.trim();
|
||||||
|
err.classList.add('hidden');
|
||||||
|
|
||||||
|
if (!_deployState.unit_id || !_deployState.photo_file) {
|
||||||
|
err.textContent = 'Need a unit and a photo first.';
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Capturing…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('unit_id', _deployState.unit_id);
|
||||||
|
fd.append('operator_note', note);
|
||||||
|
fd.append('photo', _deployState.photo_file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/deployments/capture', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
_deployState.captured = data;
|
||||||
|
_showDone(data);
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Capture';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showDone(data) {
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
document.getElementById('step-' + i).classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('step-done').classList.remove('hidden');
|
||||||
|
document.getElementById('done-unit-label').textContent = data.pending_deployment.unit_id;
|
||||||
|
const coords = data.extracted_coords;
|
||||||
|
const status = document.getElementById('done-coords-label');
|
||||||
|
if (coords) {
|
||||||
|
status.textContent = `GPS: ${coords}`;
|
||||||
|
} else {
|
||||||
|
status.textContent = 'No GPS in photo EXIF — you can add coordinates when classifying.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWizard() {
|
||||||
|
_deployState = { unit_id: null, photo_file: null, photo_preview_url: null, captured: null };
|
||||||
|
document.getElementById('unit-search').value = '';
|
||||||
|
document.getElementById('note-input').value = '';
|
||||||
|
document.getElementById('photo-input').value = '';
|
||||||
|
document.getElementById('photo-preview-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('capture-error').classList.add('hidden');
|
||||||
|
const btn = document.getElementById('capture-submit');
|
||||||
|
btn.disabled = false; btn.textContent = 'Capture';
|
||||||
|
goToStep(1);
|
||||||
|
_fetchUnitList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off initial unit list on page load.
|
||||||
|
_fetchUnitList();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -10,8 +10,8 @@ Usage:
|
|||||||
#}
|
#}
|
||||||
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
|
<div 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-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
|
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-5xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[92vh] overflow-y-auto">
|
||||||
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
|
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 z-10">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
<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,3 +23,7 @@ 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,16 +1,58 @@
|
|||||||
<!-- Project Locations List -->
|
<!-- Project Locations List — Active + Removed sections.
|
||||||
{% if locations %}
|
|
||||||
<div class="space-y-3">
|
Card layout:
|
||||||
{% for item in locations %}
|
[drag handle] [location info] [unit pill] [⋮ menu]
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
(name link, description, address, sessions/events, coords)
|
||||||
|
|
||||||
|
Active cards are draggable to reorder. Drop reorders the DOM
|
||||||
|
immediately and posts the new order to /api/projects/{p}/locations/reorder.
|
||||||
|
|
||||||
|
Removed cards are NOT reorderable (their order is historical) but
|
||||||
|
show a Restore button.
|
||||||
|
|
||||||
|
The three-dot menu replaces the inline Unassign/Edit/Remove/Delete
|
||||||
|
pill buttons. Click ⋮ to open; click outside closes.
|
||||||
|
-->
|
||||||
|
|
||||||
|
{% if not active_locations and not removed_locations %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No locations added yet</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ─── Active locations (draggable) ─── #}
|
||||||
|
{% if active_locations %}
|
||||||
|
<div class="space-y-3" id="active-locations-list" data-project-id="{{ project.id }}">
|
||||||
|
{% for item in active_locations %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors location-card"
|
||||||
|
draggable="true"
|
||||||
|
data-location-id="{{ item.location.id }}"
|
||||||
|
data-location-type="{{ item.location.location_type or 'sound' }}"
|
||||||
|
data-location-name="{{ item.location.name | e }}"
|
||||||
|
data-coordinates="{{ item.location.coordinates or '' }}"
|
||||||
|
ondragstart="onLocationDragStart(event)"
|
||||||
|
ondragover="onLocationDragOver(event)"
|
||||||
|
ondragleave="onLocationDragLeave(event)"
|
||||||
|
ondrop="onLocationDrop(event)"
|
||||||
|
ondragend="onLocationDragEnd(event)">
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<!-- Drag handle + info -->
|
||||||
|
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||||
|
<div class="shrink-0 pt-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
title="Drag to reorder">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 4a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 9a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 14a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||||
{{ item.location.name }}
|
{{ item.location.name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
{% if item.location.description %}
|
{% if item.location.description %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -20,45 +62,241 @@
|
|||||||
{% if item.location.coordinates %}
|
{% if item.location.coordinates %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
|
<span><strong class="text-gray-700 dark:text-gray-300">{{ "{:,}".format(item.event_count) }}</strong> event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Sessions: {{ item.session_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.assignment and item.assigned_unit %}
|
||||||
|
<span>Assigned: <a href="/unit/{{ item.assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy font-mono">{{ item.assigned_unit.id }}</a></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Right column: small assign/unassign pill + 3-dot menu -->
|
||||||
{% if item.assignment %}
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
{% if not item.assignment %}
|
||||||
Unassign
|
<!-- Primary action: visible because the unassigned card
|
||||||
</button>
|
is most likely getting clicked on right after creation -->
|
||||||
{% else %}
|
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
|
||||||
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Three-dot kebab menu -->
|
||||||
|
<div class="relative inline-block location-menu-wrapper">
|
||||||
|
<button onclick="toggleLocationMenu(event, this)"
|
||||||
|
class="p-1.5 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="More actions">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="location-menu hidden absolute right-0 mt-1 w-40 z-30 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||||
|
{% if item.assignment %}
|
||||||
|
<button onclick="unassignUnit('{{ item.assignment.id }}'); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
||||||
onclick="openEditLocationModal(this)"
|
onclick="openEditLocationModal(this); closeAllLocationMenus()"
|
||||||
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
<button data-loc-id="{{ item.location.id }}"
|
||||||
|
data-loc-name="{{ item.location.name | e }}"
|
||||||
|
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
||||||
|
title="Mark as no longer monitored — preserves events">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||||
|
<button onclick="deleteLocation('{{ item.location.id }}'); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
title="Permanently delete — only allowed if no history">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ─── Removed locations (collapsed by default) ─── #}
|
||||||
|
{% if removed_locations %}
|
||||||
|
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
Removed locations
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
|
||||||
|
</span>
|
||||||
|
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="space-y-3 mt-3">
|
||||||
|
{% for item in removed_locations %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
|
||||||
|
Removed
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if item.location.removal_reason %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.description %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.address %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button data-loc-id="{{ item.location.id }}"
|
||||||
|
data-loc-name="{{ item.location.name | e }}"
|
||||||
|
onclick="restoreLocation(this.dataset.locId, this.dataset.locName)"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
|
||||||
|
title="Restore to active monitoring">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
<span>Sessions: {{ item.session_count }}</span>
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
{% if item.assignment and item.assigned_unit %}
|
<span>{{ "{:,}".format(item.event_count) }} historical event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>No active assignment</span>
|
<span>Historical sessions: {{ item.session_count }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</details>
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
<p>No locations added yet</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Drag-and-drop + menu handlers, scoped to this partial (re-defined
|
||||||
|
on every htmx swap, which is harmless — function declarations
|
||||||
|
overwrite). -->
|
||||||
|
<script>
|
||||||
|
let _dragSrcCard = null;
|
||||||
|
|
||||||
|
function onLocationDragStart(e) {
|
||||||
|
_dragSrcCard = e.currentTarget;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Required for Firefox to start the drag.
|
||||||
|
e.dataTransfer.setData('text/plain', _dragSrcCard.dataset.locationId);
|
||||||
|
e.currentTarget.classList.add('opacity-40');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragOver(e) {
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
e.currentTarget.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragLeave(e) {
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
|
||||||
|
const list = document.getElementById('active-locations-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Drop AFTER the target by default; if mouse is in top half, drop BEFORE.
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const dropBefore = (e.clientY - rect.top) < rect.height / 2;
|
||||||
|
if (dropBefore) {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget);
|
||||||
|
} else {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistLocationOrder(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragEnd(e) {
|
||||||
|
e.currentTarget.classList.remove('opacity-40');
|
||||||
|
document.querySelectorAll('.location-card').forEach(c => {
|
||||||
|
c.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
});
|
||||||
|
_dragSrcCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _persistLocationOrder(list) {
|
||||||
|
const projectId = list.dataset.projectId;
|
||||||
|
const ids = Array.from(list.querySelectorAll('.location-card'))
|
||||||
|
.map(c => c.dataset.locationId);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ location_ids: ids }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({ detail: 'HTTP ' + r.status }));
|
||||||
|
throw new Error(err.detail || 'reorder failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save new order:', err);
|
||||||
|
if (typeof showToast === 'function') showToast('Failed to save new order: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Three-dot menu ─────────────────────────────────────────────────
|
||||||
|
function toggleLocationMenu(e, btn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = btn.parentElement.querySelector('.location-menu');
|
||||||
|
const wasOpen = !menu.classList.contains('hidden');
|
||||||
|
closeAllLocationMenus();
|
||||||
|
if (!wasOpen) {
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllLocationMenus() {
|
||||||
|
document.querySelectorAll('.location-menu').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menus on outside click (only register once globally).
|
||||||
|
if (!window._locationMenuOutsideClickRegistered) {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.location-menu-wrapper')) closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
window._locationMenuOutsideClickRegistered = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!-- Reusable project-location map.
|
||||||
|
|
||||||
|
Renders a Leaflet map with one pin per active monitoring location for
|
||||||
|
the given project. Fetches data from /api/projects/{p}/locations-json
|
||||||
|
on load.
|
||||||
|
|
||||||
|
Required context variable:
|
||||||
|
project_id — UUID string
|
||||||
|
|
||||||
|
Optional context variable:
|
||||||
|
map_height — CSS height (default "320px")
|
||||||
|
location_type — filter the fetched list (default: all types)
|
||||||
|
|
||||||
|
Hover any .location-card on the page with a matching
|
||||||
|
data-location-id → highlights the pin. Click a pin → scrolls
|
||||||
|
the matching card into view + flashes an orange ring.
|
||||||
|
|
||||||
|
isolation:isolate on the container forces a new stacking context so
|
||||||
|
Leaflet's internal z-indexes (200-800) stay contained inside the
|
||||||
|
card instead of leaking out and rendering over modals (z-50).
|
||||||
|
-->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500" id="loc-map-status-{{ project_id }}"></span>
|
||||||
|
</div>
|
||||||
|
<div id="loc-map-{{ project_id }}"
|
||||||
|
class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
style="height: {{ map_height | default('320px') }}; background: rgba(0,0,0,0.05); isolation: isolate;">
|
||||||
|
</div>
|
||||||
|
<div id="loc-map-empty-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
|
||||||
|
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
|
||||||
|
</div>
|
||||||
|
<div id="loc-map-missing-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const projectId = {{ project_id | tojson }};
|
||||||
|
const locationType = {{ (location_type | default(none)) | tojson }};
|
||||||
|
const mapEl = document.getElementById('loc-map-' + projectId);
|
||||||
|
const emptyMsg = document.getElementById('loc-map-empty-' + projectId);
|
||||||
|
const missingMsg = document.getElementById('loc-map-missing-' + projectId);
|
||||||
|
const statusEl = document.getElementById('loc-map-status-' + projectId);
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
function parseCoords(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||||
|
if (parts.length !== 2 || parts.some(isNaN)) return null;
|
||||||
|
const [lat, lon] = parts;
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
statusEl.textContent = 'loading…';
|
||||||
|
let locs;
|
||||||
|
try {
|
||||||
|
const qs = locationType ? `?location_type=${encodeURIComponent(locationType)}` : '';
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations-json${qs}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
locs = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'failed';
|
||||||
|
mapEl.innerHTML = `<div class="flex items-center justify-center h-full text-sm text-red-500">Map load failed: ${e.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = '';
|
||||||
|
|
||||||
|
const withCoords = [];
|
||||||
|
const withoutCoords = [];
|
||||||
|
for (const loc of locs) {
|
||||||
|
const xy = parseCoords(loc.coordinates);
|
||||||
|
if (xy) withCoords.push({ ...loc, latlon: xy });
|
||||||
|
else withoutCoords.push(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
mapEl.classList.add('hidden');
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = L.map(mapEl, { scrollWheelZoom: false });
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markersById = {};
|
||||||
|
const bounds = [];
|
||||||
|
withCoords.forEach(loc => {
|
||||||
|
const marker = L.circleMarker(loc.latlon, {
|
||||||
|
radius: 8, fillColor: '#f48b1c', color: '#fff',
|
||||||
|
weight: 2, opacity: 1, fillOpacity: 0.9,
|
||||||
|
}).addTo(map);
|
||||||
|
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
|
||||||
|
marker.on('click', () => _lmFlashCard(loc.id));
|
||||||
|
markersById[loc.id] = marker;
|
||||||
|
bounds.push(loc.latlon);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds.length === 1) map.setView(bounds[0], 14);
|
||||||
|
else map.fitBounds(bounds, { padding: [20, 20] });
|
||||||
|
setTimeout(() => map.invalidateSize(), 100);
|
||||||
|
|
||||||
|
if (withoutCoords.length > 0) {
|
||||||
|
const names = withoutCoords.map(l => l.name).join(', ');
|
||||||
|
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
|
||||||
|
missingMsg.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover any .location-card on the page → highlight matching pin.
|
||||||
|
let hoverPinId = null;
|
||||||
|
document.addEventListener('mouseover', (e) => {
|
||||||
|
const card = e.target.closest('.location-card');
|
||||||
|
if (!card) return;
|
||||||
|
const locId = card.dataset.locationId;
|
||||||
|
if (!markersById[locId] || locId === hoverPinId) return;
|
||||||
|
if (hoverPinId) _unhighlight(hoverPinId);
|
||||||
|
_highlight(locId);
|
||||||
|
hoverPinId = locId;
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseout', (e) => {
|
||||||
|
const card = e.target.closest('.location-card');
|
||||||
|
if (!card) return;
|
||||||
|
const related = e.relatedTarget && e.relatedTarget.closest('.location-card');
|
||||||
|
if (related === card) return;
|
||||||
|
if (hoverPinId) {
|
||||||
|
_unhighlight(hoverPinId);
|
||||||
|
hoverPinId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _highlight(locId) {
|
||||||
|
const m = markersById[locId]; if (!m) return;
|
||||||
|
m.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 });
|
||||||
|
m.openTooltip();
|
||||||
|
m.bringToFront();
|
||||||
|
}
|
||||||
|
function _unhighlight(locId) {
|
||||||
|
const m = markersById[locId]; if (!m) return;
|
||||||
|
m.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 });
|
||||||
|
m.closeTooltip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lmFlashCard(locId) {
|
||||||
|
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
card.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -78,22 +78,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
{# Location map — uses the reusable partial that fetches from
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
/api/projects/{p}/locations-json. Same render is reused on the
|
||||||
{% if upcoming_actions %}
|
deeper Vibration tab so both surfaces stay in sync. #}
|
||||||
<div class="space-y-3">
|
{% with project_id=project.id %}
|
||||||
{% for action in upcoming_actions %}
|
{% include 'partials/projects/location_map.html' %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
{% endwith %}
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
|
|
||||||
{% if action.description %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if upcoming_actions %}
|
||||||
|
<div class="mt-3 text-xs text-right text-gray-500 dark:text-gray-400">
|
||||||
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -74,6 +74,22 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Generate Combined Report
|
Generate Combined Report
|
||||||
</a>
|
</a>
|
||||||
|
<button onclick="openNightReportModal()"
|
||||||
|
title="Last night's noise vs baseline, per location (FTP report pipeline)"
|
||||||
|
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||||
|
</svg>
|
||||||
|
Night Report
|
||||||
|
</button>
|
||||||
|
<button onclick="openReportSettings('{{ project.id }}')"
|
||||||
|
title="Nightly report settings — schedule, baseline range, recipients"
|
||||||
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button onclick="openMergeModal()"
|
<button onclick="openMergeModal()"
|
||||||
title="Merge this project into another (consolidates duplicates)"
|
title="Merge this project into another (consolidates duplicates)"
|
||||||
@@ -87,9 +103,346 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Merge Modal -->
|
<!-- Night Report Modal -->
|
||||||
|
<div id="night-report-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Night Report</h3>
|
||||||
|
<button onclick="closeNightReportModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-5 space-y-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Last night's noise (7 PM–7 AM) vs a baseline range, per location. Opens in a new tab.</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Night (evening date)</label>
|
||||||
|
<input type="date" id="nr-night-date" 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">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline start <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||||
|
<input type="date" id="nr-baseline-start" 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">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline end</label>
|
||||||
|
<input type="date" id="nr-baseline-end" 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">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Recent reports</label>
|
||||||
|
<span id="nr-recent-count" class="text-xs text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
<div id="nr-recent" class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<div class="px-3 py-2 text-xs text-gray-400">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="nr-status" class="text-xs"></p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||||
|
<button onclick="closeNightReportModal()" class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm">Cancel</button>
|
||||||
|
<button onclick="runNightReport('{{ project.id }}')" class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm">Run & Email</button>
|
||||||
|
<button onclick="viewNightReport('{{ project.id }}')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm">View Report</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var NR_PROJECT_ID = '{{ project.id }}';
|
||||||
|
function openNightReportModal() {
|
||||||
|
var el = document.getElementById('nr-night-date');
|
||||||
|
if (el && !el.value) { // default to last night
|
||||||
|
var d = new Date(); d.setDate(d.getDate() - 1);
|
||||||
|
el.value = d.getFullYear() + '-'
|
||||||
|
+ String(d.getMonth() + 1).padStart(2, '0') + '-'
|
||||||
|
+ String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
document.getElementById('nr-status').textContent = '';
|
||||||
|
document.getElementById('night-report-modal').classList.remove('hidden');
|
||||||
|
loadRecentReports(NR_PROJECT_ID);
|
||||||
|
}
|
||||||
|
function closeNightReportModal() {
|
||||||
|
document.getElementById('night-report-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
function _nrParams() {
|
||||||
|
var night = document.getElementById('nr-night-date').value;
|
||||||
|
var bs = document.getElementById('nr-baseline-start').value;
|
||||||
|
var be = document.getElementById('nr-baseline-end').value;
|
||||||
|
if (!night) { alert('Pick a night (evening date).'); return null; }
|
||||||
|
if ((bs && !be) || (be && !bs)) { alert('Provide both baseline dates, or leave both empty.'); return null; }
|
||||||
|
var qs = 'night_date=' + night;
|
||||||
|
if (bs && be) qs += '&baseline_start=' + bs + '&baseline_end=' + be;
|
||||||
|
return qs;
|
||||||
|
}
|
||||||
|
function viewNightReport(projectId) {
|
||||||
|
var qs = _nrParams(); if (!qs) return;
|
||||||
|
window.open('/api/projects/' + projectId + '/reports/nightly/view?' + qs, '_blank');
|
||||||
|
}
|
||||||
|
function runNightReport(projectId) {
|
||||||
|
var qs = _nrParams(); if (!qs) return;
|
||||||
|
var st = document.getElementById('nr-status');
|
||||||
|
st.style.color = ''; st.textContent = 'Running…';
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/nightly/run?' + qs + '&send=true', { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'run failed'); return; }
|
||||||
|
var em = res.j.email || {};
|
||||||
|
var emailMsg = em.sent ? 'emailed' : (em.dry_run ? 'email dry-run (SMTP not set)' : (em.error || 'email skipped'));
|
||||||
|
st.style.color = '#1a7f37';
|
||||||
|
st.innerHTML = 'Done — saved & ' + _mergeEsc(emailMsg) + '. <a href="' + _mergeEsc(res.j.view_url) + '" target="_blank" class="underline">view</a>';
|
||||||
|
loadRecentReports(projectId);
|
||||||
|
})
|
||||||
|
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
|
||||||
|
}
|
||||||
|
function loadRecentReports(projectId) {
|
||||||
|
var box = document.getElementById('nr-recent');
|
||||||
|
var cnt = document.getElementById('nr-recent-count');
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/list')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
cnt.textContent = (j.count || 0) + ' generated';
|
||||||
|
if (!j.reports || !j.reports.length) {
|
||||||
|
box.innerHTML = '<div class="px-3 py-2 text-xs text-gray-400">None yet. Run one above.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = j.reports.map(function (rp) {
|
||||||
|
var when = (rp.generated_at || '').replace('T', ' ').slice(0, 16);
|
||||||
|
var xlsx = rp.xlsx_url ? ' · <a href="' + _mergeEsc(rp.xlsx_url) + '" class="text-indigo-600 dark:text-indigo-400 hover:underline">Excel</a>' : '';
|
||||||
|
return '<div class="flex items-center justify-between px-3 py-2 text-sm">'
|
||||||
|
+ '<a href="' + _mergeEsc(rp.view_url) + '" target="_blank" class="font-medium text-gray-800 dark:text-gray-200 hover:underline">Night of ' + _mergeEsc(rp.night_date) + '</a>'
|
||||||
|
+ '<span class="text-xs text-gray-400">' + _mergeEsc(when) + ' UTC' + xlsx + '</span></div>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function () { box.innerHTML = '<div class="px-3 py-2 text-xs text-red-500">Failed to load.</div>'; });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Nightly Report Settings Modal -->
|
||||||
|
<div id="report-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Nightly Report Settings</h3>
|
||||||
|
<button onclick="closeReportSettings()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-5 space-y-4">
|
||||||
|
<div id="rs-schedule-status" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/40 rounded-lg px-3 py-2"></div>
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
<input type="checkbox" id="rs-enabled" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
|
Email the report automatically each morning
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report time (local)</label>
|
||||||
|
<input type="time" id="rs-report-time" 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-400 mt-1">Runs after this time for the night that just ended.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline source</label>
|
||||||
|
<div class="flex gap-4 text-sm mb-2">
|
||||||
|
<label class="flex items-center gap-1.5"><input type="radio" name="rs-baseline-mode" value="captured" onchange="rsToggleBaselineMode()" class="text-indigo-600"> Captured nights</label>
|
||||||
|
<label class="flex items-center gap-1.5"><input type="radio" name="rs-baseline-mode" value="reference" onchange="rsToggleBaselineMode()" class="text-indigo-600"> Fixed values</label>
|
||||||
|
</div>
|
||||||
|
<div id="rs-baseline-captured" class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Range start</label>
|
||||||
|
<input type="date" id="rs-baseline-start" 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">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Range end</label>
|
||||||
|
<input type="date" id="rs-baseline-end" 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">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="rs-baseline-reference" class="hidden">
|
||||||
|
<p class="text-xs text-gray-400 mb-2">Values to compare against (a spec limit like L10 = 85, or a prior report's averages). Blank cells aren't compared.</p>
|
||||||
|
<div class="flex justify-end mb-1"><button type="button" onclick="rsCopyFirstNrl()" class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline">Copy first NRL → all</button></div>
|
||||||
|
<div id="rs-ref-grid" class="space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metrics</label>
|
||||||
|
<input type="text" id="rs-metrics" placeholder="lmax,l01,l10,l90" 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-400 mt-1">Comma list. Options: lmax, l01, l10, l50, l90, l95, leq.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipients</label>
|
||||||
|
<input type="text" id="rs-recipients" placeholder="brian@…, dad@…" 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-400 mt-1">Comma list. Blank → the default SMTP recipients.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" onclick="sendTestEmail('{{ project.id }}')" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">Send test email</button>
|
||||||
|
<span id="rs-test-status" class="text-xs ml-2"></span>
|
||||||
|
</div>
|
||||||
|
<p id="rs-status" class="text-xs"></p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||||
|
<button onclick="closeReportSettings()" class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm">Cancel</button>
|
||||||
|
<button onclick="saveReportSettings('{{ project.id }}')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function openReportSettings(projectId) {
|
||||||
|
var show = function () { document.getElementById('report-settings-modal').classList.remove('hidden'); };
|
||||||
|
document.getElementById('rs-status').textContent = '';
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/config')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (c) {
|
||||||
|
document.getElementById('rs-enabled').checked = !!c.enabled;
|
||||||
|
document.getElementById('rs-report-time').value = c.report_time || '08:00';
|
||||||
|
document.getElementById('rs-baseline-start').value = c.baseline_start || '';
|
||||||
|
document.getElementById('rs-baseline-end').value = c.baseline_end || '';
|
||||||
|
document.getElementById('rs-metrics').value = c.metric_keys || 'lmax,l01,l10,l90';
|
||||||
|
document.getElementById('rs-recipients').value = c.recipients || '';
|
||||||
|
var ss = document.getElementById('rs-schedule-status');
|
||||||
|
var last = c.last_run_date || '—';
|
||||||
|
if (c.enabled) {
|
||||||
|
ss.innerHTML = '<span style="color:#1a7f37">●</span> Automatic — runs daily at ' + (c.report_time || '08:00') + '. Last reported night: ' + last + '.';
|
||||||
|
} else {
|
||||||
|
ss.innerHTML = '<span style="color:#9ca3af">●</span> Automatic sending is off. Last reported night: ' + last + '.';
|
||||||
|
}
|
||||||
|
document.getElementById('rs-test-status').textContent = '';
|
||||||
|
rsSetMode(c.baseline_mode || 'captured');
|
||||||
|
loadBaselineEditor(projectId);
|
||||||
|
show();
|
||||||
|
})
|
||||||
|
.catch(show);
|
||||||
|
}
|
||||||
|
function closeReportSettings() {
|
||||||
|
document.getElementById('report-settings-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
function saveReportSettings(projectId) {
|
||||||
|
var st = document.getElementById('rs-status');
|
||||||
|
var mode = rsGetMode();
|
||||||
|
var bs = document.getElementById('rs-baseline-start').value;
|
||||||
|
var be = document.getElementById('rs-baseline-end').value;
|
||||||
|
if (mode === 'captured' && ((bs && !be) || (be && !bs))) {
|
||||||
|
st.style.color = '#b00020'; st.textContent = 'Provide both baseline dates, or neither.'; return;
|
||||||
|
}
|
||||||
|
var body = {
|
||||||
|
enabled: document.getElementById('rs-enabled').checked,
|
||||||
|
report_time: document.getElementById('rs-report-time').value || '08:00',
|
||||||
|
metric_keys: document.getElementById('rs-metrics').value || 'lmax,l01,l10,l90',
|
||||||
|
baseline_mode: mode,
|
||||||
|
baseline_start: bs || null,
|
||||||
|
baseline_end: be || null,
|
||||||
|
recipients: document.getElementById('rs-recipients').value || ''
|
||||||
|
};
|
||||||
|
st.style.color = ''; st.textContent = 'Saving…';
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/config', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'save failed'); return; }
|
||||||
|
if (mode === 'reference') {
|
||||||
|
return fetch('/api/projects/' + projectId + '/reports/baseline', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ locations: gatherRefValues() })
|
||||||
|
}).then(function (r2) {
|
||||||
|
if (!r2.ok) throw new Error('baseline values failed to save');
|
||||||
|
st.style.color = '#1a7f37'; st.textContent = 'Saved.'; setTimeout(closeReportSettings, 700);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
st.style.color = '#1a7f37'; st.textContent = 'Saved.'; setTimeout(closeReportSettings, 700);
|
||||||
|
})
|
||||||
|
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
|
||||||
|
}
|
||||||
|
var RS_BASELINE = { metrics: [], windows: [], locations: [] };
|
||||||
|
function rsGetMode() {
|
||||||
|
var r = document.querySelector('input[name="rs-baseline-mode"]:checked');
|
||||||
|
return r ? r.value : 'captured';
|
||||||
|
}
|
||||||
|
function rsSetMode(mode) {
|
||||||
|
document.querySelectorAll('input[name="rs-baseline-mode"]').forEach(function (el) { el.checked = (el.value === mode); });
|
||||||
|
rsToggleBaselineMode();
|
||||||
|
}
|
||||||
|
function rsToggleBaselineMode() {
|
||||||
|
var ref = rsGetMode() === 'reference';
|
||||||
|
document.getElementById('rs-baseline-captured').classList.toggle('hidden', ref);
|
||||||
|
document.getElementById('rs-baseline-reference').classList.toggle('hidden', !ref);
|
||||||
|
}
|
||||||
|
function loadBaselineEditor(projectId) {
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/baseline')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { RS_BASELINE = d; renderRefGrid(); })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
function _refId(loc, w, m) { return 'ref__' + loc + '__' + w + '__' + m; }
|
||||||
|
function renderRefGrid() {
|
||||||
|
var box = document.getElementById('rs-ref-grid');
|
||||||
|
if (!RS_BASELINE.locations || !RS_BASELINE.locations.length) {
|
||||||
|
box.innerHTML = '<div class="text-xs text-gray-400">No NRLs in this project yet.</div>'; return;
|
||||||
|
}
|
||||||
|
var W = RS_BASELINE.windows, M = RS_BASELINE.metrics;
|
||||||
|
box.innerHTML = RS_BASELINE.locations.map(function (loc) {
|
||||||
|
var head = '<tr><th></th>' + W.map(function (w) {
|
||||||
|
return '<th class="text-xs text-gray-400 font-normal pb-1 px-1">' + w.label.replace(/\s*\(.*\)/, '') + '</th>';
|
||||||
|
}).join('') + '</tr>';
|
||||||
|
var rows = M.map(function (m) {
|
||||||
|
var cells = W.map(function (w) {
|
||||||
|
var v = (loc.values[w.key] && loc.values[w.key][m.key] != null) ? loc.values[w.key][m.key] : '';
|
||||||
|
return '<td class="px-1"><input type="number" step="0.1" id="' + _refId(loc.id, w.key, m.key) + '" value="' + _mergeEsc(v) + '" class="w-16 px-1.5 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm text-center"></td>';
|
||||||
|
}).join('');
|
||||||
|
return '<tr><td class="text-sm text-gray-700 dark:text-gray-300 pr-2">' + m.label + '</td>' + cells + '</tr>';
|
||||||
|
}).join('');
|
||||||
|
return '<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-2">'
|
||||||
|
+ '<div class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">' + _mergeEsc(loc.name) + '</div>'
|
||||||
|
+ '<table class="w-full">' + head + rows + '</table></div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
function gatherRefValues() {
|
||||||
|
var out = {};
|
||||||
|
(RS_BASELINE.locations || []).forEach(function (loc) {
|
||||||
|
var wins = {};
|
||||||
|
RS_BASELINE.windows.forEach(function (w) {
|
||||||
|
var mv = {};
|
||||||
|
RS_BASELINE.metrics.forEach(function (m) {
|
||||||
|
var el = document.getElementById(_refId(loc.id, w.key, m.key));
|
||||||
|
if (el && el.value !== '') mv[m.key] = el.value;
|
||||||
|
});
|
||||||
|
if (Object.keys(mv).length) wins[w.key] = mv;
|
||||||
|
});
|
||||||
|
out[loc.id] = wins;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function rsCopyFirstNrl() {
|
||||||
|
if (!RS_BASELINE.locations || RS_BASELINE.locations.length < 2) return;
|
||||||
|
var first = RS_BASELINE.locations[0].id;
|
||||||
|
RS_BASELINE.locations.slice(1).forEach(function (loc) {
|
||||||
|
RS_BASELINE.windows.forEach(function (w) {
|
||||||
|
RS_BASELINE.metrics.forEach(function (m) {
|
||||||
|
var src = document.getElementById(_refId(first, w.key, m.key));
|
||||||
|
var dst = document.getElementById(_refId(loc.id, w.key, m.key));
|
||||||
|
if (src && dst) dst.value = src.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function sendTestEmail(projectId) {
|
||||||
|
var st = document.getElementById('rs-test-status');
|
||||||
|
st.style.color = ''; st.textContent = 'Sending…';
|
||||||
|
var recips = document.getElementById('rs-recipients').value;
|
||||||
|
fetch('/api/projects/' + projectId + '/reports/test-email', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(recips ? { recipients: recips } : {})
|
||||||
|
}).then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j.sent) { st.style.color = '#1a7f37'; st.textContent = 'Sent to ' + (j.recipients || []).join(', '); }
|
||||||
|
else if (j.dry_run) { st.style.color = '#b8860b'; st.textContent = 'Dry-run (SMTP not set) — would send to ' + (j.recipients || []).join(', '); }
|
||||||
|
else { st.style.color = '#b00020'; st.textContent = 'Error: ' + (j.error || 'failed'); }
|
||||||
|
})
|
||||||
|
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Merge Modal —
|
||||||
|
min-h on the body ensures the typeahead dropdown has room to render
|
||||||
|
below the input without forcing the operator to scroll inside the
|
||||||
|
modal. overflow-visible on the body lets the dropdown extend
|
||||||
|
beyond the body's natural height when needed. -->
|
||||||
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col"
|
||||||
|
style="min-height: 480px;">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -104,7 +457,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
<div class="px-6 py-4 overflow-y-auto flex-1 min-h-[320px]">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Target project
|
Target project
|
||||||
</label>
|
</label>
|
||||||
@@ -202,6 +555,10 @@ async function _mergeFetchTargets() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash target id + name in data-* attributes (NOT inline JS args)
|
||||||
|
// to avoid the quote-collision that breaks click binding when the
|
||||||
|
// project name contains characters JSON.stringify quotes. Same
|
||||||
|
// pattern as the backfill typeahead dropdown.
|
||||||
dropdown.innerHTML = candidates.map(m => {
|
dropdown.innerHTML = candidates.map(m => {
|
||||||
const scoreBadge = m.score >= 0.99
|
const scoreBadge = m.score >= 0.99
|
||||||
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
||||||
@@ -212,8 +569,10 @@ async function _mergeFetchTargets() {
|
|||||||
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
||||||
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||||
return `<button type="button"
|
return `<button type="button"
|
||||||
|
data-target-id="${_mergeEsc(m.id)}"
|
||||||
|
data-target-name="${_mergeEsc(m.name)}"
|
||||||
onmousedown="event.preventDefault()"
|
onmousedown="event.preventDefault()"
|
||||||
onclick="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
|
onclick="_mergePickFromButton(this)"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
||||||
${metaLine}
|
${metaLine}
|
||||||
@@ -222,6 +581,13 @@ async function _mergeFetchTargets() {
|
|||||||
dropdown.classList.remove('hidden');
|
dropdown.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trampoline — reads the button's data attributes and forwards. Keeps
|
||||||
|
// the inline onclick free of any string interpolation that could break
|
||||||
|
// HTML quoting (see notes on the same pattern in metadata_backfill.html).
|
||||||
|
function _mergePickFromButton(btn) {
|
||||||
|
onMergePickTarget(btn.dataset.targetId, btn.dataset.targetName);
|
||||||
|
}
|
||||||
|
|
||||||
async function onMergePickTarget(targetId, targetName) {
|
async function onMergePickTarget(targetId, targetName) {
|
||||||
document.getElementById('merge-target-input').value = targetName;
|
document.getElementById('merge-target-input').value = targetName;
|
||||||
document.getElementById('merge-target-id').value = targetId;
|
document.getElementById('merge-target-id').value = targetId;
|
||||||
|
|||||||
@@ -60,6 +60,10 @@
|
|||||||
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
📍 {{ loc.location_name }}
|
📍 {{ loc.location_name }}
|
||||||
|
{% if loc.removed_at %}
|
||||||
|
<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold align-middle"
|
||||||
|
title="Location no longer actively monitored — events shown are historical">removed</span>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
||||||
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
{% if units %}
|
{% if units %}
|
||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
||||||
<div class="absolute top-3 right-3 flex gap-2">
|
<div class="absolute top-3 right-3 flex gap-2 z-10">
|
||||||
|
<button onclick="event.preventDefault(); event.stopPropagation(); refreshSlmUnit('{{ unit.id }}', this);"
|
||||||
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
|
title="Refresh {{ unit.id }} from device">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
||||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
title="View live chart">
|
title="View live chart">
|
||||||
@@ -20,9 +27,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/slm/{{ unit.id }}" class="block">
|
<a href="/slm/{{ unit.id }}" class="block pr-24">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="min-w-0">
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||||
{% if unit.slm_model %}
|
{% if unit.slm_model %}
|
||||||
@@ -36,25 +42,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badge + last-check on one line (moved off the top-right so it
|
||||||
|
no longer collides with the refresh/chart/gear action icons). -->
|
||||||
|
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
{% if unit.retired %}
|
{% if unit.retired %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||||
{% elif not unit.deployed %}
|
{% elif not unit.deployed %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
||||||
{% elif unit.measurement_state == "Start" %}
|
{% elif unit.measurement_state in ["Start", "Measure"] %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
||||||
{% elif unit.is_recent %}
|
{% elif unit.is_recent %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.cache_last_seen %}
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
Last check: {{ unit.cache_last_seen|local_datetime }}
|
||||||
{% if unit.slm_last_check %}
|
{% elif unit.slm_last_check %}
|
||||||
Last check: {{ unit.slm_last_check|local_datetime }}
|
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||||
{% else %}
|
{% else %}
|
||||||
No recent check-in
|
No recent check-in
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,6 +143,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Stop Live Stream
|
Stop Live Stream
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,17 +175,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
<p id="live-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln1_label %}{{ current_status.ln1_label }}{% else %}L1{% endif %}</p>
|
||||||
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
<p id="live-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
{% if current_status and current_status.ln1 %}{{ current_status.ln1 }}{% else %}--{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
<p id="live-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln2_label %}{{ current_status.ln2_label }}{% else %}L10{% endif %}</p>
|
||||||
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
<p id="live-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
{% if current_status and current_status.ln2 %}{{ current_status.ln2 }}{% else %}--{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,6 +434,24 @@ function initializeChart() {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L1',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(139, 92, 246)',
|
||||||
|
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L10',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(245, 158, 11)',
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -493,7 +513,37 @@ if (typeof window.currentWebSocket === 'undefined') {
|
|||||||
window.currentWebSocket = null;
|
window.currentWebSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initLiveDataStream(unitId) {
|
// Backfill the chart with the recent DOD trail so it opens with context.
|
||||||
|
async function backfillChart(unitId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
const readings = d.readings || [];
|
||||||
|
if (!window.chartData) return;
|
||||||
|
for (const row of readings) {
|
||||||
|
// Trail timestamps are naive UTC; append 'Z' so they convert to local
|
||||||
|
// consistently with the live frames (which use local Date.now()).
|
||||||
|
window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
||||||
|
window.chartData.lp.push(parseFloat(row.lp || 0));
|
||||||
|
window.chartData.leq.push(parseFloat(row.leq || 0));
|
||||||
|
window.chartData.ln1.push(parseFloat(row.ln1 || 0));
|
||||||
|
window.chartData.ln2.push(parseFloat(row.ln2 || 0));
|
||||||
|
}
|
||||||
|
if (window.liveChart) {
|
||||||
|
window.liveChart.data.labels = window.chartData.timestamps;
|
||||||
|
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||||
|
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||||
|
window.liveChart.data.datasets[2].data = window.chartData.ln1;
|
||||||
|
window.liveChart.data.datasets[3].data = window.chartData.ln2;
|
||||||
|
window.liveChart.update('none');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Chart backfill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initLiveDataStream(unitId) {
|
||||||
// Close existing connection if any
|
// Close existing connection if any
|
||||||
if (window.currentWebSocket) {
|
if (window.currentWebSocket) {
|
||||||
window.currentWebSocket.close();
|
window.currentWebSocket.close();
|
||||||
@@ -504,17 +554,24 @@ function initLiveDataStream(unitId) {
|
|||||||
window.chartData.timestamps = [];
|
window.chartData.timestamps = [];
|
||||||
window.chartData.lp = [];
|
window.chartData.lp = [];
|
||||||
window.chartData.leq = [];
|
window.chartData.leq = [];
|
||||||
|
window.chartData.ln1 = [];
|
||||||
|
window.chartData.ln2 = [];
|
||||||
}
|
}
|
||||||
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||||
window.liveChart.data.labels = [];
|
window.liveChart.data.labels = [];
|
||||||
window.liveChart.data.datasets[0].data = [];
|
window.liveChart.data.datasets.forEach(ds => ds.data = []);
|
||||||
window.liveChart.data.datasets[1].data = [];
|
|
||||||
window.liveChart.update();
|
window.liveChart.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy
|
// Seed the chart with recent history BEFORE opening the live socket, so live
|
||||||
|
// frames append after the backfill (right order) and the chart isn't blank.
|
||||||
|
await backfillChart(unitId);
|
||||||
|
|
||||||
|
// WebSocket URL for SLMM backend via proxy.
|
||||||
|
// /monitor = the shared fan-out DOD feed (many viewers, one device connection,
|
||||||
|
// and it carries L1/L10 which the DRD /stream cannot).
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`;
|
||||||
|
|
||||||
window.currentWebSocket = new WebSocket(wsUrl);
|
window.currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -530,7 +587,11 @@ function initLiveDataStream(unitId) {
|
|||||||
window.currentWebSocket.onmessage = function(event) {
|
window.currentWebSocket.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log('WebSocket data received:', data);
|
// The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a
|
||||||
|
// 'feed_status' on each frame. Reflect status, but don't let a heartbeat
|
||||||
|
// or an 'unreachable' frame blank the cards / spike the chart with zeros.
|
||||||
|
updateFeedStatus(data.feed_status);
|
||||||
|
if (data.heartbeat || data.feed_status === 'unreachable') return;
|
||||||
updateLiveMetrics(data);
|
updateLiveMetrics(data);
|
||||||
updateLiveChart(data);
|
updateLiveChart(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -559,6 +620,21 @@ function stopLiveDataStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflect device reachability from the monitor feed's feed_status. Safe no-op
|
||||||
|
// if the badge element isn't on the page.
|
||||||
|
function updateFeedStatus(status) {
|
||||||
|
const el = document.getElementById('live-feed-status');
|
||||||
|
if (!el || status == null) return;
|
||||||
|
if (status === 'unreachable') {
|
||||||
|
el.textContent = 'Device offline';
|
||||||
|
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Live';
|
||||||
|
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
|
||||||
|
}
|
||||||
|
el.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Update metrics display
|
// Update metrics display
|
||||||
function updateLiveMetrics(data) {
|
function updateLiveMetrics(data) {
|
||||||
if (document.getElementById('live-lp')) {
|
if (document.getElementById('live-lp')) {
|
||||||
@@ -570,11 +646,20 @@ function updateLiveMetrics(data) {
|
|||||||
if (document.getElementById('live-lmax')) {
|
if (document.getElementById('live-lmax')) {
|
||||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||||
}
|
}
|
||||||
if (document.getElementById('live-lmin')) {
|
// Only update Ln values when the frame actually carries them. DRD stream
|
||||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
// frames omit percentiles (DOD-only), so without this guard a live stream
|
||||||
|
// would blank L1/L10 over the values rendered from the cached DOD snapshot.
|
||||||
|
if (data.ln1 != null && document.getElementById('live-ln1')) {
|
||||||
|
document.getElementById('live-ln1').textContent = data.ln1;
|
||||||
}
|
}
|
||||||
if (document.getElementById('live-lpeak')) {
|
if (data.ln1_label && document.getElementById('live-ln1-label')) {
|
||||||
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
document.getElementById('live-ln1-label').textContent = data.ln1_label;
|
||||||
|
}
|
||||||
|
if (data.ln2 != null && document.getElementById('live-ln2')) {
|
||||||
|
document.getElementById('live-ln2').textContent = data.ln2;
|
||||||
|
}
|
||||||
|
if (data.ln2_label && document.getElementById('live-ln2-label')) {
|
||||||
|
document.getElementById('live-ln2-label').textContent = data.ln2_label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +668,9 @@ if (typeof window.chartData === 'undefined') {
|
|||||||
window.chartData = {
|
window.chartData = {
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: []
|
leq: [],
|
||||||
|
ln1: [],
|
||||||
|
ln2: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,12 +680,17 @@ function updateLiveChart(data) {
|
|||||||
window.chartData.timestamps.push(now.toLocaleTimeString());
|
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||||
window.chartData.lp.push(parseFloat(data.lp || 0));
|
window.chartData.lp.push(parseFloat(data.lp || 0));
|
||||||
window.chartData.leq.push(parseFloat(data.leq || 0));
|
window.chartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
window.chartData.ln1.push(parseFloat(data.ln1 || 0));
|
||||||
|
window.chartData.ln2.push(parseFloat(data.ln2 || 0));
|
||||||
|
|
||||||
// Keep only last 60 data points
|
// Keep a rolling window large enough to hold the ~2h backfill (one point/min)
|
||||||
if (window.chartData.timestamps.length > 60) {
|
// plus a good run of live points before the oldest scroll off.
|
||||||
|
if (window.chartData.timestamps.length > 600) {
|
||||||
window.chartData.timestamps.shift();
|
window.chartData.timestamps.shift();
|
||||||
window.chartData.lp.shift();
|
window.chartData.lp.shift();
|
||||||
window.chartData.leq.shift();
|
window.chartData.leq.shift();
|
||||||
|
window.chartData.ln1.shift();
|
||||||
|
window.chartData.ln2.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update chart if available
|
// Update chart if available
|
||||||
@@ -606,6 +698,8 @@ function updateLiveChart(data) {
|
|||||||
window.liveChart.data.labels = window.chartData.timestamps;
|
window.liveChart.data.labels = window.chartData.timestamps;
|
||||||
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||||
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||||
|
window.liveChart.data.datasets[2].data = window.chartData.ln1;
|
||||||
|
window.liveChart.data.datasets[3].data = window.chartData.ln2;
|
||||||
window.liveChart.update('none');
|
window.liveChart.update('none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
|
|||||||
if (typeof checkFTPStatus === 'function') {
|
if (typeof checkFTPStatus === 'function') {
|
||||||
checkFTPStatus(unitId);
|
checkFTPStatus(unitId);
|
||||||
}
|
}
|
||||||
if (typeof htmx !== 'undefined') {
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
||||||
htmx.trigger('#slm-list', 'load');
|
htmx.trigger('#slm-list', 'load');
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -604,8 +604,10 @@ async function toggleSLMDeployed() {
|
|||||||
successDiv.classList.remove('hidden');
|
successDiv.classList.remove('hidden');
|
||||||
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
||||||
|
|
||||||
// Refresh any SLM list on the page
|
// Refresh any SLM list on the page (only if one is actually present —
|
||||||
if (typeof htmx !== 'undefined') {
|
// the detail/dashboard pages have no #slm-list, and htmx.trigger on a
|
||||||
|
// null target throws "can't access property dispatchEvent, e is null").
|
||||||
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
||||||
htmx.trigger('#slm-list', 'load');
|
htmx.trigger('#slm-list', 'load');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "portal/base.html" %}
|
||||||
|
{% block title %}Access{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto mt-20 text-center reveal">
|
||||||
|
<div class="panel inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6">
|
||||||
|
<svg class="w-7 h-7 text-[var(--text-dim)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% if reason == "invalid" %}
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight mb-2">This link isn't valid</h1>
|
||||||
|
<p class="text-[var(--text-dim)] text-sm leading-relaxed">The access link is expired or has been revoked.<br>Please contact TMI for a new link.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight mb-2">Access link required</h1>
|
||||||
|
<p class="text-[var(--text-dim)] text-sm leading-relaxed">Open the monitoring link TMI sent you to view your locations.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Monitoring{% endblock %} · TMI</title>
|
||||||
|
|
||||||
|
<!-- apply saved theme before paint (no flash); light is the default -->
|
||||||
|
<script>(function(){var t=localStorage.getItem('portal-theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: { extend: {
|
||||||
|
colors: { seismo: { orange: '#f48b1c', navy: '#142a66', burgundy: '#7d234d' } },
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Hanken Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
||||||
|
},
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
||||||
|
<meta name="theme-color" content="#eef2f9">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ---- dark (default) ---- */
|
||||||
|
:root {
|
||||||
|
--bg: #080b14;
|
||||||
|
--grid: rgba(124, 146, 188, 0.045);
|
||||||
|
--aurora-1: rgba(20, 42, 102, 0.55);
|
||||||
|
--aurora-2: rgba(125, 35, 77, 0.18);
|
||||||
|
--text: #e7ecf6;
|
||||||
|
--text-dim: #8c98b0;
|
||||||
|
--border: rgba(124, 146, 188, 0.14);
|
||||||
|
--border-bright: rgba(168, 188, 224, 0.30);
|
||||||
|
--panel-a: rgba(24, 33, 54, 0.72);
|
||||||
|
--panel-b: rgba(12, 18, 31, 0.62);
|
||||||
|
--panel-inset: rgba(255, 255, 255, 0.05);
|
||||||
|
--panel-shadow: 0 22px 48px -28px rgba(0, 0, 0, 0.85);
|
||||||
|
--header-bg: rgba(8, 11, 20, 0.72);
|
||||||
|
--accent: #f48b1c;
|
||||||
|
--accent-glow: rgba(244, 139, 28, 0.40);
|
||||||
|
--lvl-ok: #34d399; --lvl-warn: #fbbf24; --lvl-bad: #f87171;
|
||||||
|
--m-lp: #60a5fa; --m-lmax: #f87171; --m-l1: #c084fc; --m-l10: #fbbf24;
|
||||||
|
}
|
||||||
|
/* ---- light (cool) — solid cards on a cool ground ---- */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #eef2f9; /* cool light */
|
||||||
|
--grid: rgba(20, 42, 102, 0.05); /* cool faint grid */
|
||||||
|
--aurora-1: rgba(120, 150, 220, 0.18); /* cool wash */
|
||||||
|
--aurora-2: rgba(244, 139, 28, 0.08); /* faint brand accent */
|
||||||
|
--text: #16203a; /* cool navy ink */
|
||||||
|
--text-dim: #5d6b86; /* cool muted */
|
||||||
|
--border: rgba(20, 42, 102, 0.13);
|
||||||
|
--border-bright: rgba(20, 42, 102, 0.18);
|
||||||
|
--panel-a: #ffffff; /* solid — kept from the un-ghosting pass */
|
||||||
|
--panel-b: #f7f9fc;
|
||||||
|
--panel-inset: rgba(255, 255, 255, 0.9);
|
||||||
|
--panel-shadow: 0 14px 30px -16px rgba(40, 55, 95, 0.22), 0 2px 6px -2px rgba(40, 55, 95, 0.07);
|
||||||
|
--header-bg: rgba(238, 242, 249, 0.85);
|
||||||
|
--lvl-ok: #16a34a; --lvl-warn: #d97706; --lvl-bad: #dc2626;
|
||||||
|
--m-lp: #2563eb; --m-lmax: #dc2626; --m-l1: #9333ea; --m-l10: #d97706;
|
||||||
|
}
|
||||||
|
/* On light, the hover-lift shadow wants cool depth (the dark one vanishes on light). */
|
||||||
|
html[data-theme="light"] .panel-hover:hover {
|
||||||
|
box-shadow: 0 22px 44px -20px rgba(40, 55, 95, 0.26), 0 0 0 1px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-feature-settings: "ss01";
|
||||||
|
background-color: var(--bg);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1100px 560px at 50% -12%, var(--aurora-1), transparent 68%),
|
||||||
|
radial-gradient(700px 400px at 88% 8%, var(--aurora-2), transparent 70%),
|
||||||
|
linear-gradient(var(--grid) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||||
|
background-size: auto, auto, 46px 46px, 46px 46px;
|
||||||
|
background-attachment: fixed;
|
||||||
|
transition: background-color .3s ease, color .3s ease;
|
||||||
|
}
|
||||||
|
::selection { background: rgba(244, 139, 28, 0.30); }
|
||||||
|
|
||||||
|
.font-mono, .reading { font-family: "IBM Plex Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 1px 0 var(--panel-inset) inset, var(--panel-shadow);
|
||||||
|
}
|
||||||
|
.panel::before {
|
||||||
|
content: ''; position: absolute; inset: 0 0 auto 0; height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--border-bright), transparent);
|
||||||
|
}
|
||||||
|
.panel-hover { transition: transform .22s ease, border-color .22s ease, box-shadow .22s ease; }
|
||||||
|
.panel-hover:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: rgba(244, 139, 28, 0.55);
|
||||||
|
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.hairline { border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* metric accent colors (flip per theme) */
|
||||||
|
.c-lp { color: var(--m-lp); } .c-lmax { color: var(--m-lmax); }
|
||||||
|
.c-l1 { color: var(--m-l1); } .c-l10 { color: var(--m-l10); }
|
||||||
|
|
||||||
|
.live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 0 var(--accent-glow); animation: pulse 2.2s infinite; }
|
||||||
|
@keyframes pulse { 0% { box-shadow: 0 0 0 0 var(--accent-glow); } 70% { box-shadow: 0 0 0 9px rgba(244, 139, 28, 0); } 100% { box-shadow: 0 0 0 0 rgba(244, 139, 28, 0); } }
|
||||||
|
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
|
.reveal { opacity: 0; animation: rise .55s cubic-bezier(.2, .7, .2, 1) forwards; }
|
||||||
|
|
||||||
|
.signal-bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
|
||||||
|
.signal-bars i { width: 3px; background: var(--accent); border-radius: 1px; animation: bars 1.4s ease-in-out infinite; }
|
||||||
|
.signal-bars i:nth-child(1) { height: 40%; } .signal-bars i:nth-child(2) { height: 70%; animation-delay: .2s; }
|
||||||
|
.signal-bars i:nth-child(3) { height: 100%; animation-delay: .4s; } .signal-bars i:nth-child(4) { height: 55%; animation-delay: .6s; }
|
||||||
|
@keyframes bars { 0%, 100% { transform: scaleY(.5); opacity: .7; } 50% { transform: scaleY(1); opacity: 1; } }
|
||||||
|
|
||||||
|
.theme-toggle { color: var(--text-dim); transition: color .2s ease, background .2s ease; }
|
||||||
|
.theme-toggle:hover { color: var(--text); }
|
||||||
|
html[data-theme="light"] .moon { display: none; }
|
||||||
|
html[data-theme="dark"] .sun, :root:not([data-theme="light"]) .sun { display: none; }
|
||||||
|
|
||||||
|
/* Leaflet polish (dark default; .leaflet-light tweaks tooltip for light) */
|
||||||
|
.leaflet-container { background: var(--bg) !important; }
|
||||||
|
.leaflet-tooltip { background: var(--panel-a); border: 1px solid var(--border-bright); color: var(--text); box-shadow: none; font-family: inherit; font-size: 12px; }
|
||||||
|
.leaflet-tooltip-top::before { border-top-color: var(--border-bright); }
|
||||||
|
.leaflet-control-attribution { background: rgba(0,0,0,0.25) !important; color: var(--text-dim) !important; }
|
||||||
|
.leaflet-control-attribution a { color: var(--text-dim) !important; }
|
||||||
|
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(124, 146, 188, 0.22); border-radius: 9px; }
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="min-h-full antialiased">
|
||||||
|
<header class="sticky top-0 z-30 border-b border-[var(--border)] bg-[var(--header-bg)] backdrop-blur-xl">
|
||||||
|
<div class="max-w-5xl mx-auto px-5 py-3.5 flex items-center justify-between">
|
||||||
|
<a href="/portal" class="flex items-center gap-2.5">
|
||||||
|
<span class="signal-bars"><i></i><i></i><i></i><i></i></span>
|
||||||
|
<span class="font-semibold tracking-tight text-[15px]">
|
||||||
|
TMI <span class="text-[var(--text-dim)] font-normal">Monitoring</span>
|
||||||
|
{% if client %}<span class="text-[var(--text-dim)] font-normal mx-0.5">/</span>
|
||||||
|
<span class="text-seismo-orange">{{ client.name }}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button onclick="togglePortalTheme()" class="theme-toggle p-2 rounded-lg" title="Toggle light / dark" aria-label="Toggle theme">
|
||||||
|
<svg class="moon w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
|
||||||
|
<svg class="sun w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
||||||
|
</button>
|
||||||
|
{% if client %}
|
||||||
|
<a href="/portal/logout" class="text-[13px] text-[var(--text-dim)] hover:text-[var(--text)] transition-colors px-2">Sign out</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-5 py-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="max-w-5xl mx-auto px-5 py-10 text-[11px] text-[var(--text-dim)] flex items-center gap-2 opacity-70">
|
||||||
|
<span class="w-1 h-1 rounded-full bg-[var(--text-dim)]"></span>
|
||||||
|
Read-only monitoring view · data provided as-is for informational purposes
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Theme toggle. Pages can listen for 'portal-theme' to re-skin canvases/maps.
|
||||||
|
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
||||||
|
// HTML-escape operator-set strings (location/rule names) before innerHTML/tooltip injection.
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||||
|
function togglePortalTheme() {
|
||||||
|
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||||
|
const next = cur === 'light' ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
try { localStorage.setItem('portal-theme', next); } catch (e) {}
|
||||||
|
const mc = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (mc) mc.setAttribute('content', next === 'light' ? '#eef2f9' : '#080b14');
|
||||||
|
document.dispatchEvent(new CustomEvent('portal-theme', { detail: next }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
{% extends "portal/base.html" %}
|
||||||
|
{% block title %}{{ location.name }}{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<a href="/portal" class="reveal inline-flex items-center gap-1.5 text-sm text-[var(--text-dim)] hover:text-[var(--text)] 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="M15 19l-7-7 7-7"/></svg>
|
||||||
|
All locations
|
||||||
|
</a>
|
||||||
|
<div class="reveal mt-3 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">{{ location.name }}</h1>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span id="p-badge" class="hidden"></span>
|
||||||
|
<span id="p-fresh" class="text-[var(--text-dim)] font-mono text-xs"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not has_device %}
|
||||||
|
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No device is currently assigned to this location.</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="p-alarm-banner" class="hidden reveal mt-5 px-4 py-3 rounded-xl bg-[rgba(220,38,38,0.10)] border border-[rgba(220,38,38,0.32)] text-[var(--lvl-bad)] text-sm flex items-center gap-2.5">
|
||||||
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="p-alarm-text" class="font-medium">Currently above threshold.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero console: Leq primary + instrument strip -->
|
||||||
|
<div class="panel reveal mt-5 p-6 sm:p-7" style="animation-delay:60ms">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-1.5">Leq · average</div>
|
||||||
|
<div class="flex items-baseline gap-2.5">
|
||||||
|
<span id="p-leq" class="reading text-6xl sm:text-7xl leading-none font-semibold">--</span>
|
||||||
|
<span class="text-sm text-[var(--text-dim)] font-mono">dB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hairline mt-6 pt-5 grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lp · instant</div>
|
||||||
|
<div class="mt-1 flex items-baseline gap-1"><span id="p-lp" class="reading text-2xl font-semibold c-lp">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lmax · peak</div>
|
||||||
|
<div class="mt-1 flex items-baseline gap-1"><span id="p-lmax" class="reading text-2xl font-semibold c-lmax">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L1</div>
|
||||||
|
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln1" class="reading text-2xl font-semibold c-l1">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L10</div>
|
||||||
|
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln2" class="reading text-2xl font-semibold c-l10">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live trace -->
|
||||||
|
<div class="panel reveal mt-5 overflow-hidden" style="animation-delay:120ms">
|
||||||
|
<div class="px-5 pt-4 text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono">Live trace · last 2h</div>
|
||||||
|
<div class="relative px-3 pb-3 pt-2" style="min-height: 340px;">
|
||||||
|
<canvas id="p-chart"></canvas>
|
||||||
|
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-[rgba(8,11,20,0.78)] rounded-xl backdrop-blur-sm">
|
||||||
|
<button onclick="resumeStream()"
|
||||||
|
class="px-4 py-2 rounded-lg bg-seismo-orange/15 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/25 text-sm font-medium transition-colors">
|
||||||
|
⏸ Live paused — tap to resume
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert limits (what this location is alerted on) -->
|
||||||
|
<div id="p-limits-section" class="reveal mt-7 hidden" style="animation-delay:180ms">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert limits</div>
|
||||||
|
<div id="p-thresholds" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert history -->
|
||||||
|
<div class="reveal mt-7" style="animation-delay:220ms">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert history</div>
|
||||||
|
<div id="p-events" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if has_device %}
|
||||||
|
<script>
|
||||||
|
const LOC_ID = "{{ location.id }}";
|
||||||
|
const cd = { t: [], lp: [], leq: [], ln1: [], ln2: [] };
|
||||||
|
let chart;
|
||||||
|
const numOrNull = v => { const f = parseFloat(v); return isNaN(f) ? null : f; };
|
||||||
|
|
||||||
|
// Level color for the Leq hero (matches the overview bands).
|
||||||
|
const LEVEL_AMBER = 55, LEVEL_RED = 70;
|
||||||
|
function leqColor(measuring, v) {
|
||||||
|
// CSS var refs so the hero color auto-flips with the theme.
|
||||||
|
if (!measuring || v == null || isNaN(v)) return 'var(--text)';
|
||||||
|
if (v >= LEVEL_RED) return 'var(--lvl-bad)';
|
||||||
|
if (v >= LEVEL_AMBER) return 'var(--lvl-warn)';
|
||||||
|
return 'var(--lvl-ok)';
|
||||||
|
}
|
||||||
|
function paintLeq(measuring, leqVal) {
|
||||||
|
const el = document.getElementById('p-leq');
|
||||||
|
if (el) el.style.color = leqColor(measuring, parseFloat(leqVal));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ds(label) { return { label, data: [], borderWidth: 1.5, pointRadius: 0, tension: 0.35, spanGaps: true }; }
|
||||||
|
function skinChart() {
|
||||||
|
if (!chart) return;
|
||||||
|
const dim = cssVar('--text-dim');
|
||||||
|
const cols = [cssVar('--m-lp'), cssVar('--lvl-ok'), cssVar('--m-l1'), cssVar('--m-l10')];
|
||||||
|
chart.data.datasets.forEach((d, i) => { d.borderColor = cols[i]; d.backgroundColor = cols[i]; });
|
||||||
|
const grid = 'rgba(124,146,188,0.10)', gridX = 'rgba(124,146,188,0.05)', border = 'rgba(124,146,188,0.18)';
|
||||||
|
const y = chart.options.scales.y, x = chart.options.scales.x;
|
||||||
|
y.ticks.color = dim; y.title.color = dim; y.grid.color = grid; y.border.color = border;
|
||||||
|
x.ticks.color = dim; x.grid.color = gridX; x.border.color = border;
|
||||||
|
chart.options.plugins.legend.labels.color = cssVar('--text');
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
function initChart() {
|
||||||
|
const ctx = document.getElementById('p-chart').getContext('2d');
|
||||||
|
const mono = { family: 'IBM Plex Mono', size: 10 };
|
||||||
|
chart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [ds('Lp'), ds('Leq'), ds('L1'), ds('L10')] },
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false,
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
scales: {
|
||||||
|
y: { min: 30, max: 130, title: { display: true, text: 'dB', font: { family: 'IBM Plex Mono' } },
|
||||||
|
ticks: { font: mono }, grid: {}, border: {} },
|
||||||
|
x: { ticks: { font: mono, maxTicksLimit: 8 }, grid: {}, border: {} }
|
||||||
|
},
|
||||||
|
plugins: { legend: { labels: { font: { family: 'Hanken Grotesk' }, usePointStyle: true, pointStyleWidth: 10, boxHeight: 7 } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
skinChart();
|
||||||
|
}
|
||||||
|
document.addEventListener('portal-theme', skinChart);
|
||||||
|
|
||||||
|
function setCard(id, v) { document.getElementById(id).textContent = (v == null || v === '') ? '--' : v; }
|
||||||
|
function setBadge(measuring, lastSeen) {
|
||||||
|
const b = document.getElementById('p-badge'), f = document.getElementById('p-fresh');
|
||||||
|
const base = 'inline-flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded-full border ';
|
||||||
|
if (measuring === null) { b.className = 'hidden'; b.textContent = ''; }
|
||||||
|
else if (measuring) { b.className = base + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; b.innerHTML = '<span class="live-dot"></span> Live'; }
|
||||||
|
else { b.className = base + 'border-[var(--border)] text-[var(--text-dim)]'; b.textContent = 'Stopped'; }
|
||||||
|
f.innerHTML = fmtFreshness(lastSeen);
|
||||||
|
}
|
||||||
|
function fmtFreshness(iso) {
|
||||||
|
if (!iso) return '<span class="text-[var(--text-dim)]">no recent reading</span>';
|
||||||
|
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
||||||
|
let ago, stale = false;
|
||||||
|
if (s < 10) ago = 'just now';
|
||||||
|
else if (s < 60) ago = s + 's ago';
|
||||||
|
else if (s < 3600) { ago = Math.round(s / 60) + 'm ago'; stale = s >= 300; }
|
||||||
|
else { ago = Math.round(s / 3600) + 'h ago'; stale = true; }
|
||||||
|
const cls = stale ? 'text-amber-400' : 'text-[var(--text-dim)]';
|
||||||
|
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${stale ? ' · cached' : ''})</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefill() {
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/live`)).json();
|
||||||
|
const d = j.data;
|
||||||
|
if (!d) {
|
||||||
|
setBadge(null, null);
|
||||||
|
document.getElementById('p-fresh').textContent =
|
||||||
|
j.reason === 'no_device' ? 'No device assigned' : 'Currently unreachable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
||||||
|
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
setBadge(measuring, d.last_seen);
|
||||||
|
paintLeq(measuring, d.leq);
|
||||||
|
} catch (e) { /* keep last values */ }
|
||||||
|
}
|
||||||
|
async function backfill() {
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/history?hours=2`)).json();
|
||||||
|
for (const row of (j.readings || [])) {
|
||||||
|
cd.t.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
||||||
|
cd.lp.push(numOrNull(row.lp)); cd.leq.push(numOrNull(row.leq));
|
||||||
|
cd.ln1.push(numOrNull(row.ln1)); cd.ln2.push(numOrNull(row.ln2));
|
||||||
|
}
|
||||||
|
chart.data.labels = cd.t;
|
||||||
|
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
||||||
|
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
||||||
|
chart.update('none');
|
||||||
|
} catch (e) { /* leave chart empty */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- live stream (upgrades the cache prefill to a real ~1Hz feed) --------
|
||||||
|
let ws = null, hardCap = null, paused = false;
|
||||||
|
const IDLE_CAP_MS = 15 * 60 * 1000; // auto-close after 15 min so an abandoned
|
||||||
|
// tab doesn't pin the device at 1Hz polling
|
||||||
|
|
||||||
|
function pushPoint(d) {
|
||||||
|
cd.t.push(new Date().toLocaleTimeString());
|
||||||
|
cd.lp.push(numOrNull(d.lp)); cd.leq.push(numOrNull(d.leq));
|
||||||
|
cd.ln1.push(numOrNull(d.ln1)); cd.ln2.push(numOrNull(d.ln2));
|
||||||
|
if (cd.t.length > 600) { cd.t.shift(); cd.lp.shift(); cd.leq.shift(); cd.ln1.shift(); cd.ln2.shift(); }
|
||||||
|
chart.data.labels = cd.t;
|
||||||
|
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
||||||
|
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStream() {
|
||||||
|
if (paused || ws) return;
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}/portal/api/location/${encodeURIComponent(LOC_ID)}/stream`);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
|
||||||
|
if (d.feed_status === 'no_device') {
|
||||||
|
setBadge(null, null);
|
||||||
|
document.getElementById('p-fresh').textContent = 'No device assigned';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.heartbeat) return;
|
||||||
|
if (d.feed_status === 'unreachable') {
|
||||||
|
document.getElementById('p-fresh').innerHTML = '<span class="text-amber-400">device unreachable</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
||||||
|
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
setBadge(measuring, d.timestamp || new Date().toISOString());
|
||||||
|
paintLeq(measuring, d.leq);
|
||||||
|
pushPoint(d);
|
||||||
|
};
|
||||||
|
ws.onclose = () => { ws = null; };
|
||||||
|
ws.onerror = () => {};
|
||||||
|
clearTimeout(hardCap);
|
||||||
|
hardCap = setTimeout(() => { paused = true; closeStream(); showPaused(true); }, IDLE_CAP_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeStream() {
|
||||||
|
clearTimeout(hardCap);
|
||||||
|
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPaused(on) {
|
||||||
|
const el = document.getElementById('p-paused');
|
||||||
|
if (el) el.classList.toggle('hidden', !on);
|
||||||
|
}
|
||||||
|
function resumeStream() {
|
||||||
|
paused = false; showPaused(false);
|
||||||
|
prefill(); // refresh cards instantly on resume
|
||||||
|
openStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop streaming when the tab is hidden (client switched away / locked phone) and
|
||||||
|
// resume when it's visible again — the main cost guard, so the device relaxes back
|
||||||
|
// to its idle poll rate the moment nobody is actually looking.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) closeStream();
|
||||||
|
else if (!paused) openStream();
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', closeStream);
|
||||||
|
|
||||||
|
// ---- alert history + current-alarm banner (read-only) --------------------
|
||||||
|
const EV_METRIC = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
||||||
|
function fmtAlertTime(iso) { return iso ? new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString() : ''; }
|
||||||
|
|
||||||
|
// ---- alert limits (the active thresholds, read-only) ---------------------
|
||||||
|
function fmtThreshold(r) {
|
||||||
|
const m = EV_METRIC[r.metric] || esc(r.metric);
|
||||||
|
const cmp = r.comparison === 'below' ? 'below' : 'above';
|
||||||
|
let s = `${m} ${cmp} ${r.threshold_db} dB`;
|
||||||
|
if (r.duration_s) s += ` for ${r.duration_s}s`;
|
||||||
|
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}–${r.schedule_end}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
async function loadThresholds() {
|
||||||
|
const sec = document.getElementById('p-limits-section');
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/thresholds`)).json();
|
||||||
|
const rules = j.rules || [];
|
||||||
|
if (!rules.length) { sec.classList.add('hidden'); return; }
|
||||||
|
const list = document.getElementById('p-thresholds');
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const r of rules) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'panel px-3.5 py-2.5 text-sm flex items-center gap-2.5';
|
||||||
|
row.innerHTML = `<span class="w-1.5 h-1.5 rounded-full bg-seismo-orange shrink-0"></span>
|
||||||
|
<span class="text-[var(--text)]">${esc(r.name || 'Alert')}</span>
|
||||||
|
<span class="text-[var(--text-dim)] font-mono text-xs">${fmtThreshold(r)}</span>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
sec.classList.remove('hidden');
|
||||||
|
} catch (e) { sec.classList.add('hidden'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json();
|
||||||
|
const events = j.events || [];
|
||||||
|
const banner = document.getElementById('p-alarm-banner');
|
||||||
|
if (j.active) {
|
||||||
|
banner.classList.remove('hidden');
|
||||||
|
document.getElementById('p-alarm-text').textContent =
|
||||||
|
j.active > 1 ? `${j.active} alerts currently active` : 'Currently above threshold.';
|
||||||
|
} else banner.classList.add('hidden');
|
||||||
|
const list = document.getElementById('p-events');
|
||||||
|
if (!events.length) { list.innerHTML = '<div class="text-sm text-[var(--text-dim)]">No alerts have fired.</div>'; return; }
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const e of events) {
|
||||||
|
const m = EV_METRIC[e.metric] || esc(e.metric);
|
||||||
|
const active = e.status === 'active';
|
||||||
|
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
|
||||||
|
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
|
||||||
|
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'panel px-3.5 py-2.5 text-sm ' + (active ? 'border-[rgba(220,38,38,0.4)]' : '');
|
||||||
|
row.innerHTML = `<div class="${active ? 'text-[var(--lvl-bad)] font-medium' : 'text-[var(--text)]'}">${esc(e.rule_name || 'Alert')} <span class="text-xs text-[var(--text-dim)] font-mono">· ${m} ${e.threshold_db} dB</span></div>
|
||||||
|
<div class="text-xs text-[var(--text-dim)] font-mono mt-0.5">${when}${peak}</div>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
} catch (e) { /* leave history as-is */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
initChart();
|
||||||
|
prefill(); // instant first paint from cache
|
||||||
|
backfill(); // seed the chart trail
|
||||||
|
openStream(); // then upgrade to the live feed
|
||||||
|
loadEvents();
|
||||||
|
loadThresholds();
|
||||||
|
setInterval(loadEvents, 20000);
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
{% extends "portal/base.html" %}
|
||||||
|
{% block title %}Your locations{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.2em] text-seismo-orange/80 font-mono mb-2">Live monitoring</div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Your locations</h1>
|
||||||
|
<p class="text-[var(--text-dim)] text-sm mt-1">Real-time sound levels across your active monitoring sites.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if locations %}
|
||||||
|
<!-- Status rollup (filled live from the per-location /live fetches) -->
|
||||||
|
<div id="rollup" class="hidden mt-6 mb-6 flex flex-wrap items-center gap-2.5">
|
||||||
|
<div class="panel px-4 py-2.5 flex items-center gap-2.5">
|
||||||
|
<span class="text-[var(--text-dim)] text-[10px] uppercase tracking-[0.15em]">Locations</span>
|
||||||
|
<b id="r-total" class="reading text-lg font-semibold">–</b>
|
||||||
|
</div>
|
||||||
|
<div class="panel px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="live-dot"></span><b id="r-live" class="reading text-lg font-semibold text-seismo-orange">–</b><span class="text-[var(--text-dim)] text-xs">live</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-[var(--text-dim)]/50"></span><b id="r-off" class="reading text-lg font-semibold">–</b><span class="text-[var(--text-dim)] text-xs">offline</span>
|
||||||
|
</div>
|
||||||
|
<div id="r-peak-wrap" class="hidden panel px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-[var(--text-dim)] text-[10px] uppercase tracking-[0.15em]">Loudest now</span>
|
||||||
|
<b id="r-peak" class="reading text-lg font-semibold text-seismo-orange">–</b><span class="text-[var(--text-dim)] text-xs">dB</span>
|
||||||
|
<span id="r-peak-loc" class="text-[var(--text-dim)] text-sm"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loc-map" class="panel reveal hidden h-72 overflow-hidden mb-6" style="animation-delay:80ms"></div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for loc in locations %}
|
||||||
|
<a href="/portal/location/{{ loc.id }}" data-loc="{{ loc.id }}"
|
||||||
|
class="loc-tile panel panel-hover reveal block p-5" style="animation-delay: {{ (loop.index0 * 55) + 140 }}ms">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-semibold tracking-tight truncate">{{ loc.name }}</div>
|
||||||
|
<div class="text-xs text-[var(--text-dim)] mt-0.5 truncate">{{ loc.address or loc.project_name or '' }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="loc-badge hidden shrink-0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex items-baseline gap-1.5">
|
||||||
|
<span class="loc-leq reading text-[2.6rem] leading-none font-semibold">--</span>
|
||||||
|
<span class="text-xs text-[var(--text-dim)] font-mono tracking-wide">dB Leq</span>
|
||||||
|
</div>
|
||||||
|
<div class="loc-fresh text-[11px] text-[var(--text-dim)]/70 mt-2 font-mono"> </div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No active monitoring locations yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script>
|
||||||
|
const LOCATIONS = {{ locations|tojson }};
|
||||||
|
const liveState = {}; // loc.id -> {status, leq(num|null), leqStr}
|
||||||
|
const markersById = {}; // loc.id -> circleMarker (for live recolor)
|
||||||
|
let tiles = null; // map tile layer (re-skinned on theme toggle)
|
||||||
|
|
||||||
|
// Dot/level color (computed hex; reads the theme CSS vars so it flips with theme).
|
||||||
|
const LEVEL_AMBER = 55, LEVEL_RED = 70;
|
||||||
|
function levelColor(st) {
|
||||||
|
if (!st || st.status !== 'measuring' || st.leq == null) return cssVar('--text-dim');
|
||||||
|
if (st.leq >= LEVEL_RED) return cssVar('--lvl-bad');
|
||||||
|
if (st.leq >= LEVEL_AMBER) return cssVar('--lvl-warn');
|
||||||
|
return cssVar('--lvl-ok');
|
||||||
|
}
|
||||||
|
function tileUrl() {
|
||||||
|
return document.documentElement.getAttribute('data-theme') === 'light'
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||||
|
}
|
||||||
|
// Re-skin map tiles + recolor everything when the theme flips.
|
||||||
|
document.addEventListener('portal-theme', () => { if (tiles) tiles.setUrl(tileUrl()); refreshAll(); });
|
||||||
|
function num(v) { const f = parseFloat(v); return isNaN(f) ? null : f; }
|
||||||
|
|
||||||
|
function fmtAgo(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
||||||
|
if (s < 60) return 'updated just now';
|
||||||
|
if (s < 3600) return 'updated ' + Math.round(s / 60) + 'm ago';
|
||||||
|
return 'updated ' + Math.round(s / 3600) + 'h ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE_BASE = 'loc-badge inline-flex items-center gap-1.5 shrink-0 px-2.5 py-1 text-[11px] rounded-full border ';
|
||||||
|
|
||||||
|
function updateMarker(loc) {
|
||||||
|
const m = markersById[loc.id]; if (!m) return;
|
||||||
|
const st = liveState[loc.id];
|
||||||
|
m.setStyle({ fillColor: levelColor(st) });
|
||||||
|
let label = `<b>${esc(loc.name)}</b>`;
|
||||||
|
if (st) {
|
||||||
|
if (st.status === 'measuring') label += ` · ${esc(st.leqStr)} dB Leq`;
|
||||||
|
else if (st.status === 'stopped') label += ' · stopped';
|
||||||
|
else if (st.status === 'nodevice') label += ' · no device';
|
||||||
|
else label += ' · offline';
|
||||||
|
}
|
||||||
|
m.setTooltipContent(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTile(loc) {
|
||||||
|
const el = document.querySelector(`.loc-tile[data-loc="${loc.id}"]`);
|
||||||
|
const leqEl = el && el.querySelector('.loc-leq'),
|
||||||
|
badge = el && el.querySelector('.loc-badge'),
|
||||||
|
fresh = el && el.querySelector('.loc-fresh');
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(loc.id)}/live`)).json();
|
||||||
|
const d = j.data;
|
||||||
|
if (!d) {
|
||||||
|
liveState[loc.id] = { status: j.reason === 'no_device' ? 'nodevice' : 'offline', leq: null };
|
||||||
|
if (badge) { badge.classList.remove('hidden'); badge.className = BADGE_BASE + 'border-[var(--border)] text-[var(--text-dim)]'; badge.textContent = j.reason === 'no_device' ? 'No device' : 'Offline'; }
|
||||||
|
if (leqEl) { leqEl.textContent = '--'; leqEl.style.color = 'var(--text-dim)'; }
|
||||||
|
if (fresh) fresh.innerHTML = ' ';
|
||||||
|
} else {
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
const leqStr = (d.leq == null || d.leq === '') ? '--' : d.leq;
|
||||||
|
liveState[loc.id] = { status: measuring ? 'measuring' : 'stopped', leq: num(d.leq), leqStr };
|
||||||
|
if (leqEl) { leqEl.textContent = leqStr; leqEl.style.color = measuring ? levelColor(liveState[loc.id]) : 'var(--text)'; }
|
||||||
|
if (badge) {
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
if (measuring) { badge.className = BADGE_BASE + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; badge.innerHTML = '<span class="live-dot"></span> Live'; }
|
||||||
|
else { badge.className = BADGE_BASE + 'border-[var(--border)] text-[var(--text-dim)]'; badge.textContent = 'Stopped'; }
|
||||||
|
}
|
||||||
|
if (fresh) fresh.textContent = fmtAgo(d.last_seen);
|
||||||
|
}
|
||||||
|
} catch (e) { /* leave placeholders */ }
|
||||||
|
updateMarker(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRollup() {
|
||||||
|
const total = LOCATIONS.length;
|
||||||
|
let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null;
|
||||||
|
for (const l of LOCATIONS) {
|
||||||
|
const s = liveState[l.id]; if (!s) continue;
|
||||||
|
if (s.status === 'measuring') {
|
||||||
|
live++;
|
||||||
|
if (s.leq != null && (peak == null || s.leq > peak)) { peak = s.leq; peakStr = s.leqStr; peakLoc = l.name; }
|
||||||
|
} else if (s.status === 'offline' || s.status === 'nodevice') off++;
|
||||||
|
}
|
||||||
|
document.getElementById('r-total').textContent = total;
|
||||||
|
document.getElementById('r-live').textContent = live;
|
||||||
|
document.getElementById('r-off').textContent = off;
|
||||||
|
const pw = document.getElementById('r-peak-wrap');
|
||||||
|
if (peak != null) {
|
||||||
|
pw.classList.remove('hidden');
|
||||||
|
document.getElementById('r-peak').textContent = peakStr;
|
||||||
|
document.getElementById('r-peak-loc').textContent = peakLoc;
|
||||||
|
} else pw.classList.add('hidden');
|
||||||
|
document.getElementById('rollup').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all(LOCATIONS.map(loadTile));
|
||||||
|
updateRollup();
|
||||||
|
}
|
||||||
|
refreshAll();
|
||||||
|
setInterval(refreshAll, 15000);
|
||||||
|
|
||||||
|
// Map of locations with coordinates — dark tiles, dots recolor live.
|
||||||
|
const withCoords = LOCATIONS.filter(l => l.coordinates);
|
||||||
|
if (withCoords.length) {
|
||||||
|
const mapEl = document.getElementById('loc-map');
|
||||||
|
mapEl.classList.remove('hidden');
|
||||||
|
const map = L.map('loc-map', { scrollWheelZoom: false, attributionControl: true });
|
||||||
|
tiles = L.tileLayer(tileUrl(), {
|
||||||
|
maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap © CARTO'
|
||||||
|
}).addTo(map);
|
||||||
|
const pts = [];
|
||||||
|
withCoords.forEach(l => {
|
||||||
|
const [la, lo] = (l.coordinates || '').split(',').map(Number);
|
||||||
|
if (!isNaN(la) && !isNaN(lo)) {
|
||||||
|
markersById[l.id] = L.circleMarker([la, lo], {
|
||||||
|
radius: 7, fillColor: levelColor(liveState[l.id]), color: '#fff',
|
||||||
|
weight: 2, opacity: 0.9, fillOpacity: 0.95,
|
||||||
|
}).addTo(map).bindTooltip(esc(l.name), { direction: 'top', offset: [0, -6] });
|
||||||
|
pts.push([la, lo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (pts.length) map.fitBounds(pts, { padding: [36, 36], maxZoom: 15 });
|
||||||
|
else mapEl.classList.add('hidden');
|
||||||
|
LOCATIONS.forEach(updateMarker);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Breadcrumb Navigation -->
|
<!-- Breadcrumb Navigation -->
|
||||||
<div class="mb-6">
|
<div class="mb-6 flex items-center justify-between gap-3">
|
||||||
<nav class="flex items-center space-x-2 text-sm">
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -17,6 +17,28 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Client portal actions for this project -->
|
||||||
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
|
<button type="button" onclick="openShareModal()"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
|
||||||
|
title="Get a shareable link to this project's client portal">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 11-5.656-5.656l1.5-1.5"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 115.656 5.656l-1.5 1.5"></path>
|
||||||
|
</svg>
|
||||||
|
Copy client link
|
||||||
|
</button>
|
||||||
|
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
|
||||||
|
title="Preview this project's client portal in a new tab">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
View client portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header (loads dynamically) -->
|
<!-- Header (loads dynamically) -->
|
||||||
@@ -101,7 +123,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 lg:col-span-2">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<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')"
|
||||||
@@ -119,6 +142,16 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@@ -778,6 +811,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Remove Location Confirmation Modal —
|
||||||
|
Soft-removal: preserves historical events, closes active assignments,
|
||||||
|
cancels pending scheduled actions. Distinct from Delete (which is
|
||||||
|
permanent and only allowed when there's no history). -->
|
||||||
|
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||||
|
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white">…</span> as no longer actively monitored.
|
||||||
|
</p>
|
||||||
|
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
|
||||||
|
<li>Closes any active unit assignment at this location</li>
|
||||||
|
<li>Cancels pending scheduled actions at this location</li>
|
||||||
|
<li>Historical events stay attributed (visible in reports + event lists)</li>
|
||||||
|
<li>Can be restored later if needed</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<input type="hidden" id="remove-location-id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
|
||||||
|
<input type="datetime-local" id="remove-location-effective"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
|
||||||
|
<input type="text" id="remove-location-reason" maxlength="200"
|
||||||
|
placeholder="e.g. client dropped from scope"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="closeRemoveLocationModal()"
|
||||||
|
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="confirmRemoveLocation()"
|
||||||
|
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Project Confirmation Modal -->
|
<!-- Delete Project Confirmation Modal -->
|
||||||
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
@@ -1213,6 +1301,94 @@ async function deleteLocation(locationId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remove / Restore location ────────────────────────────────────────
|
||||||
|
// Soft-removal: marks a location as no longer actively monitored without
|
||||||
|
// destroying it. Historical events stay attributed; active assignments
|
||||||
|
// are auto-closed and pending scheduled actions are auto-cancelled.
|
||||||
|
|
||||||
|
function openRemoveLocationModal(locationId, locationName) {
|
||||||
|
document.getElementById('remove-location-id').value = locationId;
|
||||||
|
document.getElementById('remove-location-name').textContent = locationName;
|
||||||
|
document.getElementById('remove-location-reason').value = '';
|
||||||
|
// Default effective_date to "now" in local datetime-input format.
|
||||||
|
const now = new Date();
|
||||||
|
const tzOffsetMin = now.getTimezoneOffset();
|
||||||
|
const local = new Date(now.getTime() - tzOffsetMin * 60000);
|
||||||
|
document.getElementById('remove-location-effective').value =
|
||||||
|
local.toISOString().slice(0, 16);
|
||||||
|
document.getElementById('remove-location-error').classList.add('hidden');
|
||||||
|
document.getElementById('remove-location-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRemoveLocationModal() {
|
||||||
|
document.getElementById('remove-location-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveLocation() {
|
||||||
|
const locationId = document.getElementById('remove-location-id').value;
|
||||||
|
const reason = document.getElementById('remove-location-reason').value.trim();
|
||||||
|
const effective = document.getElementById('remove-location-effective').value;
|
||||||
|
const errBox = document.getElementById('remove-location-error');
|
||||||
|
errBox.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/locations/${locationId}/remove`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
reason: reason || null,
|
||||||
|
effective_date: effective || null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to remove location');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
closeRemoveLocationModal();
|
||||||
|
refreshLocationLists();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
// Lightweight feedback — the UI refresh already shows the location
|
||||||
|
// moving to the Removed section, but a toast confirms the cascade.
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
const bits = [];
|
||||||
|
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
|
||||||
|
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
|
||||||
|
const tail = bits.length ? ` (${bits.join(', ')})` : '';
|
||||||
|
showToast(`Location removed${tail}`, 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errBox.textContent = err.message || 'Failed to remove location.';
|
||||||
|
errBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreLocation(locationId, locationName) {
|
||||||
|
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/locations/${locationId}/restore`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to restore location');
|
||||||
|
}
|
||||||
|
refreshLocationLists();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(`"${locationName}" restored to active`, 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to restore location.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Assign modal functions
|
// Assign modal functions
|
||||||
function openAssignModal(locationId, locationType) {
|
function openAssignModal(locationId, locationType) {
|
||||||
const safeType = locationType || 'sound';
|
const safeType = locationType || 'sound';
|
||||||
@@ -1920,5 +2096,125 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Share client portal link modal -->
|
||||||
|
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onclick="if(event.target===this)closeShareModal()">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal link</h3>
|
||||||
|
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Anyone with a link can view this project's client portal (read-only). Links are revocable.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if portal_open_links %}
|
||||||
|
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) -->
|
||||||
|
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||||
|
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="open-url" readonly
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
|
||||||
|
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="share-new" class="hidden mb-4">
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link — copy it now</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="share-new-url" readonly
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
|
||||||
|
<button onclick="copyShareUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Active links</span>
|
||||||
|
<button onclick="generateShareLink()" class="text-sm text-seismo-orange hover:text-seismo-navy font-medium">+ Generate new link</button>
|
||||||
|
</div>
|
||||||
|
<div id="share-list" class="space-y-2 max-h-56 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SHARE_PROJECT_ID = "{{ project_id }}";
|
||||||
|
function openShareModal() {
|
||||||
|
document.getElementById('share-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('share-new').classList.add('hidden');
|
||||||
|
const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on
|
||||||
|
if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
|
||||||
|
loadShareLinks();
|
||||||
|
}
|
||||||
|
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
function copyOpenUrl(btn) {
|
||||||
|
const inp = document.getElementById('open-url');
|
||||||
|
inp.select();
|
||||||
|
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
|
||||||
|
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
|
||||||
|
else { document.execCommand('copy'); done(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShareLinks() {
|
||||||
|
const list = document.getElementById('share-list');
|
||||||
|
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-links`)).json();
|
||||||
|
if (!j.links || !j.links.length) {
|
||||||
|
list.innerHTML = '<div class="text-sm text-gray-400">No links yet — generate one above.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const l of j.links) {
|
||||||
|
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
|
||||||
|
row.innerHTML = `<div class="text-sm min-w-0">
|
||||||
|
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div>
|
||||||
|
<div class="text-xs text-gray-400">${last}</div></div>`;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'shrink-0 text-xs text-red-600 hover:text-red-700';
|
||||||
|
btn.textContent = 'Revoke';
|
||||||
|
btn.onclick = () => revokeShareLink(l.id);
|
||||||
|
row.appendChild(btn);
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateShareLink() {
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link`, { method: 'POST' })).json();
|
||||||
|
if (j.url) {
|
||||||
|
document.getElementById('share-new').classList.remove('hidden');
|
||||||
|
document.getElementById('share-new-url').value = j.url;
|
||||||
|
loadShareLinks();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (window.showToast) showToast('Failed to generate link', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyShareUrl(btn) {
|
||||||
|
const inp = document.getElementById('share-new-url');
|
||||||
|
inp.select();
|
||||||
|
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
|
||||||
|
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
|
||||||
|
else { document.execCommand('copy'); done(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeShareLink(id) {
|
||||||
|
if (!confirm('Revoke this link? Anyone using it will be signed out on their next action.')) return;
|
||||||
|
try { await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link/${id}/revoke`, { method: 'POST' }); loadShareLinks(); }
|
||||||
|
catch (e) { if (window.showToast) showToast('Failed to revoke', 'error'); }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+84
-1
@@ -122,6 +122,21 @@
|
|||||||
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="psi" selected>psi — raw pressure (matches PDF report)</option>
|
||||||
|
<option value="dBL">dB(L) — sound pressure level</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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">
|
||||||
@@ -457,6 +472,20 @@
|
|||||||
<button onclick="saveCalibrationDefaults()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button onclick="saveCalibrationDefaults()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
Save Defaults
|
Save Defaults
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Sync from SFM events</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Reads <code>calibration_date</code> from each seismograph's most recent event sidecar and updates
|
||||||
|
<em>Last Calibrated</em> when the device reports a newer date than what's stored.
|
||||||
|
Manual edits made after the latest event are preserved. Runs automatically once a day.
|
||||||
|
</p>
|
||||||
|
<button onclick="runCalibrationSync()" id="cal-sync-btn"
|
||||||
|
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
<div id="cal-sync-result" class="mt-3 text-sm text-gray-700 dark:text-gray-300"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -589,6 +618,20 @@
|
|||||||
</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. #}
|
||||||
@@ -757,6 +800,9 @@ 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 || 'psi';
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -774,6 +820,7 @@ 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', {
|
||||||
@@ -782,7 +829,8 @@ 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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -856,6 +904,41 @@ async function saveCalibrationDefaults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCalibrationSync() {
|
||||||
|
const btn = document.getElementById('cal-sync-btn');
|
||||||
|
const out = document.getElementById('cal-sync-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalLabel = btn.textContent;
|
||||||
|
btn.textContent = 'Syncing…';
|
||||||
|
out.textContent = '';
|
||||||
|
out.className = 'mt-3 text-sm text-gray-700 dark:text-gray-300';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/calibration/sync', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
out.className = 'mt-3 text-sm text-red-600 dark:text-red-400';
|
||||||
|
out.textContent = 'Error: ' + (data.detail || response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
|
`Checked ${data.checked}`,
|
||||||
|
`Updated ${data.updated}`,
|
||||||
|
`Already in sync ${data.already_in_sync}`,
|
||||||
|
`Manual kept ${data.skipped_manual_newer}`,
|
||||||
|
`No event ${data.no_event}`,
|
||||||
|
];
|
||||||
|
if (data.errors) parts.push(`Errors ${data.errors}`);
|
||||||
|
out.textContent = parts.join(' · ');
|
||||||
|
} catch (error) {
|
||||||
|
out.className = 'mt-3 text-sm text-red-600 dark:text-red-400';
|
||||||
|
out.textContent = 'Error: ' + error.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== DATA TAB - IMPORT/EXPORT ==========
|
// ========== DATA TAB - IMPORT/EXPORT ==========
|
||||||
|
|
||||||
// Merge Mode Import
|
// Merge Mode Import
|
||||||
|
|||||||
@@ -118,6 +118,13 @@
|
|||||||
{# 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 {
|
||||||
|
|||||||
@@ -112,4 +112,267 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mt-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">Alerts
|
||||||
|
<span id="alert-state-badge" class="hidden text-xs px-2 py-0.5 rounded-full"></span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Threshold rules evaluated on this device's live feed. An enabled alert keeps the device monitored 24/7.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openAlertForm()" type="button"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">+ Add alert</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert-rules-list" class="space-y-2"></div>
|
||||||
|
|
||||||
|
<!-- create / edit form -->
|
||||||
|
<div id="alert-form" class="hidden mt-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900/40">
|
||||||
|
<input type="hidden" id="ar-id">
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Name</label>
|
||||||
|
<input id="ar-name" type="text" placeholder="e.g. Night noise limit"
|
||||||
|
class="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
</div>
|
||||||
|
<label class="flex items-end gap-2 text-sm text-gray-700 dark:text-gray-300 pb-1">
|
||||||
|
<input type="checkbox" id="ar-enabled" checked class="rounded"> Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span>Alert when</span>
|
||||||
|
<select id="ar-metric" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<option value="leq">Leq</option><option value="lp">Lp</option>
|
||||||
|
<option value="lmax">Lmax</option><option value="lpeak">Lpeak</option>
|
||||||
|
<option value="ln1">L1</option><option value="ln2">L10</option>
|
||||||
|
</select>
|
||||||
|
<span>is</span>
|
||||||
|
<select id="ar-comparison" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<option value="above">above</option><option value="below">below</option>
|
||||||
|
</select>
|
||||||
|
<input id="ar-threshold" type="number" step="0.1" placeholder="65"
|
||||||
|
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>dB</span>
|
||||||
|
<span>for</span>
|
||||||
|
<input id="ar-duration" type="number" min="0" value="0"
|
||||||
|
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>seconds</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" id="ar-sched-on" onchange="toggleSchedule()" class="rounded"> Only during certain hours
|
||||||
|
</label>
|
||||||
|
<div id="ar-sched" class="hidden mt-2 flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span>from</span><input id="ar-start" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<span>to</span><input id="ar-end" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<span class="ml-2">on</span>
|
||||||
|
<span id="ar-days" class="flex gap-1"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer select-none">Advanced</summary>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
<span>Clear margin</span><input id="ar-margin" type="number" step="0.1" value="2"
|
||||||
|
class="w-16 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>dB (hysteresis)</span>
|
||||||
|
<span>Cooldown</span><input id="ar-cooldown" type="number" min="0" value="300"
|
||||||
|
class="w-20 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>s</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button onclick="saveAlertRule()" type="button" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Save</button>
|
||||||
|
<button onclick="closeAlertForm()" type="button" class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert history -->
|
||||||
|
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">History</h3>
|
||||||
|
<button onclick="loadAlertEvents()" type="button" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="alert-events" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ALERT_UNIT = "{{ unit_id }}";
|
||||||
|
const METRIC_LABELS = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
||||||
|
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Mon=0 .. Sun=6
|
||||||
|
|
||||||
|
// Render the day checkboxes once.
|
||||||
|
(function () {
|
||||||
|
const wrap = document.getElementById('ar-days');
|
||||||
|
DAY_LABELS.forEach((lbl, i) => {
|
||||||
|
const l = document.createElement('label');
|
||||||
|
l.className = 'inline-flex items-center gap-0.5';
|
||||||
|
l.innerHTML = `<input type="checkbox" id="ar-day-${i}" class="rounded"><span class="ml-0.5">${lbl}</span>`;
|
||||||
|
wrap.appendChild(l);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function condText(r) {
|
||||||
|
const m = METRIC_LABELS[r.metric] || r.metric;
|
||||||
|
let s = `${m} ${r.comparison} ${r.threshold_db} dB`;
|
||||||
|
if (r.duration_s) s += ` for ${r.duration_s}s`;
|
||||||
|
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}–${r.schedule_end}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRule(r) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
|
||||||
|
row.innerHTML = `<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${r.name}${r.enabled ? '' : ' <span class="text-xs text-gray-400">(disabled)</span>'}</div>
|
||||||
|
<div class="text-xs text-gray-500">${condText(r)}</div></div>
|
||||||
|
<div class="shrink-0 flex items-center gap-3 text-xs">
|
||||||
|
<button data-act="edit" class="text-seismo-orange hover:underline">Edit</button>
|
||||||
|
<button data-act="del" class="text-red-600 hover:underline">Delete</button>
|
||||||
|
</div>`;
|
||||||
|
row.querySelector('[data-act="edit"]').onclick = () => openAlertForm(r);
|
||||||
|
row.querySelector('[data-act="del"]').onclick = () => deleteAlertRule(r.id);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlertRules() {
|
||||||
|
const list = document.getElementById('alert-rules-list');
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules`)).json();
|
||||||
|
const rules = j.rules || [];
|
||||||
|
if (!rules.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts configured.</div>'; return; }
|
||||||
|
list.innerHTML = '';
|
||||||
|
rules.forEach(r => list.appendChild(renderRule(r)));
|
||||||
|
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load alerts.</div>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSchedule() {
|
||||||
|
document.getElementById('ar-sched').classList.toggle('hidden', !document.getElementById('ar-sched-on').checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAlertForm(r) {
|
||||||
|
document.getElementById('alert-form').classList.remove('hidden');
|
||||||
|
document.getElementById('ar-id').value = r ? r.id : '';
|
||||||
|
document.getElementById('ar-name').value = r ? r.name : '';
|
||||||
|
document.getElementById('ar-metric').value = r ? r.metric : 'leq';
|
||||||
|
document.getElementById('ar-comparison').value = r ? r.comparison : 'above';
|
||||||
|
document.getElementById('ar-threshold').value = (r && r.threshold_db != null) ? r.threshold_db : '';
|
||||||
|
document.getElementById('ar-duration').value = r ? r.duration_s : 0;
|
||||||
|
document.getElementById('ar-enabled').checked = r ? r.enabled : true;
|
||||||
|
document.getElementById('ar-margin').value = r ? r.clear_margin_db : 2;
|
||||||
|
document.getElementById('ar-cooldown').value = r ? r.cooldown_s : 300;
|
||||||
|
const hasSched = !!(r && r.schedule_start && r.schedule_end);
|
||||||
|
document.getElementById('ar-sched-on').checked = hasSched;
|
||||||
|
document.getElementById('ar-start').value = hasSched ? r.schedule_start : '';
|
||||||
|
document.getElementById('ar-end').value = hasSched ? r.schedule_end : '';
|
||||||
|
const days = (r && r.schedule_days) ? r.schedule_days.split(',') : [];
|
||||||
|
DAY_LABELS.forEach((_, i) => { document.getElementById('ar-day-' + i).checked = days.includes(String(i)); });
|
||||||
|
toggleSchedule();
|
||||||
|
}
|
||||||
|
function closeAlertForm() { document.getElementById('alert-form').classList.add('hidden'); }
|
||||||
|
|
||||||
|
async function saveAlertRule() {
|
||||||
|
const id = document.getElementById('ar-id').value;
|
||||||
|
const threshold = parseFloat(document.getElementById('ar-threshold').value);
|
||||||
|
if (isNaN(threshold)) { if (window.showToast) showToast('Enter a threshold', 'error'); return; }
|
||||||
|
const schedOn = document.getElementById('ar-sched-on').checked;
|
||||||
|
const days = DAY_LABELS.map((_, i) => document.getElementById('ar-day-' + i).checked ? i : null).filter(v => v !== null);
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('ar-name').value || 'Alert',
|
||||||
|
metric: document.getElementById('ar-metric').value,
|
||||||
|
comparison: document.getElementById('ar-comparison').value,
|
||||||
|
threshold_db: threshold,
|
||||||
|
duration_s: parseInt(document.getElementById('ar-duration').value) || 0,
|
||||||
|
clear_margin_db: parseFloat(document.getElementById('ar-margin').value) || 2,
|
||||||
|
cooldown_s: parseInt(document.getElementById('ar-cooldown').value) || 300,
|
||||||
|
schedule_start: schedOn ? (document.getElementById('ar-start').value || null) : null,
|
||||||
|
schedule_end: schedOn ? (document.getElementById('ar-end').value || null) : null,
|
||||||
|
schedule_days: (schedOn && days.length) ? days.join(',') : null,
|
||||||
|
enabled: document.getElementById('ar-enabled').checked,
|
||||||
|
};
|
||||||
|
const url = id ? `/api/slmm/${ALERT_UNIT}/alerts/rules/${id}` : `/api/slmm/${ALERT_UNIT}/alerts/rules`;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
|
if (!r.ok) throw new Error('save failed');
|
||||||
|
closeAlertForm(); loadAlertRules();
|
||||||
|
if (window.showToast) showToast('Alert saved', 'success');
|
||||||
|
} catch (e) { if (window.showToast) showToast('Failed to save alert', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlertRule(id) {
|
||||||
|
if (!confirm('Delete this alert rule?')) return;
|
||||||
|
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules/${id}`, { method: 'DELETE' }); loadAlertRules(); }
|
||||||
|
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- alert history (events) ----------------------------------------------
|
||||||
|
|
||||||
|
function fmtAlertTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAlertState(events) {
|
||||||
|
const badge = document.getElementById('alert-state-badge');
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
const active = events.filter(e => e.status === 'active').length;
|
||||||
|
if (active) {
|
||||||
|
badge.textContent = `● ${active} active`;
|
||||||
|
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
||||||
|
} else {
|
||||||
|
badge.textContent = '✓ All clear';
|
||||||
|
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvent(e) {
|
||||||
|
const m = METRIC_LABELS[e.metric] || e.metric;
|
||||||
|
const active = e.status === 'active';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border ' +
|
||||||
|
(active ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700');
|
||||||
|
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
|
||||||
|
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
|
||||||
|
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
|
||||||
|
const ack = e.acknowledged_at ? ` · ack'd${e.acknowledged_by ? ' by ' + e.acknowledged_by : ''}` : '';
|
||||||
|
row.innerHTML = `<div class="min-w-0">
|
||||||
|
<div class="text-sm truncate">
|
||||||
|
<span class="${active ? 'text-red-600 dark:text-red-400 font-medium' : 'text-gray-800 dark:text-gray-200'}">${e.rule_name || 'Alert'}</span>
|
||||||
|
<span class="text-xs text-gray-500"> · ${m} ${e.threshold_db} dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">${when}${peak}${ack}</div></div>`;
|
||||||
|
if (!e.acknowledged_at) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'shrink-0 text-xs text-seismo-orange hover:underline';
|
||||||
|
btn.textContent = 'Ack';
|
||||||
|
btn.onclick = () => ackEvent(e.id);
|
||||||
|
row.appendChild(btn);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlertEvents() {
|
||||||
|
const list = document.getElementById('alert-events');
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events?limit=50`)).json();
|
||||||
|
const events = j.events || [];
|
||||||
|
updateAlertState(events);
|
||||||
|
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts have fired.</div>'; return; }
|
||||||
|
list.innerHTML = '';
|
||||||
|
events.forEach(e => list.appendChild(renderEvent(e)));
|
||||||
|
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load history.</div>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ackEvent(id) {
|
||||||
|
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events/${id}/ack`, { method: 'POST' }); loadAlertEvents(); }
|
||||||
|
catch (e) { if (window.showToast) showToast('Failed to acknowledge', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAlertRules();
|
||||||
|
loadAlertEvents();
|
||||||
|
setInterval(loadAlertEvents, 20000); // surface new breaches / clears
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -51,14 +51,32 @@
|
|||||||
|
|
||||||
<!-- Live Measurement Chart - shows when a device is selected -->
|
<!-- Live Measurement Chart - shows when a device is selected -->
|
||||||
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-start justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Live Measurements
|
||||||
|
<span id="panel-unit-id" class="text-seismo-orange"></span>
|
||||||
|
</h2>
|
||||||
|
<!-- Measuring state + cache freshness (populated from cached /status, no device hit) -->
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-sm">
|
||||||
|
<span id="panel-measuring-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
|
||||||
|
<span id="panel-freshness" class="text-gray-500 dark:text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="refreshDashboardPanel()" title="Refresh from device"
|
||||||
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange">
|
||||||
|
<svg id="panel-refresh-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
<!-- Current Metrics -->
|
||||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||||
@@ -81,14 +99,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
<p id="chart-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L1</p>
|
||||||
<p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
<p id="chart-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
<p id="chart-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L10</p>
|
||||||
<p id="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
<p id="chart-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,9 +168,18 @@ window.selectedUnitId = null;
|
|||||||
window.dashboardChartData = {
|
window.dashboardChartData = {
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: []
|
leq: [],
|
||||||
|
ln1: [],
|
||||||
|
ln2: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Parse a metric to a number, or null (so a missing/"-.-" percentile leaves a gap
|
||||||
|
// in the line instead of dropping it to 0).
|
||||||
|
function numOrNull(v) {
|
||||||
|
const f = parseFloat(v);
|
||||||
|
return isNaN(f) ? null : f;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Chart.js
|
// Initialize Chart.js
|
||||||
function initializeDashboardChart() {
|
function initializeDashboardChart() {
|
||||||
if (typeof Chart === 'undefined') {
|
if (typeof Chart === 'undefined') {
|
||||||
@@ -194,6 +221,26 @@ function initializeDashboardChart() {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L1',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(168, 85, 247)',
|
||||||
|
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L10',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(249, 115, 22)',
|
||||||
|
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
spanGaps: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -244,12 +291,24 @@ function showLiveChart(unitId) {
|
|||||||
initializeDashboardChart();
|
initializeDashboardChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset data
|
// Reset data for the newly-selected unit (clears any prior unit's line)
|
||||||
window.dashboardChartData = {
|
window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] };
|
||||||
timestamps: [],
|
if (window.dashboardChart) {
|
||||||
lp: [],
|
window.dashboardChart.data.labels = [];
|
||||||
leq: []
|
window.dashboardChart.data.datasets.forEach(ds => ds.data = []);
|
||||||
};
|
window.dashboardChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name the unit; clear stale status until the cache read returns
|
||||||
|
const unitLabel = document.getElementById('panel-unit-id');
|
||||||
|
if (unitLabel) unitLabel.textContent = '· ' + unitId;
|
||||||
|
setPanelStatus(null, null);
|
||||||
|
|
||||||
|
// Populate immediately from CACHE (no device hit): KPI cards + chart trail.
|
||||||
|
prefillDashboardPanel(unitId);
|
||||||
|
backfillDashboardChart(unitId);
|
||||||
|
// Keep the cards updating from cache (~15s) without opening a device stream.
|
||||||
|
startPanelCachePolling(unitId);
|
||||||
|
|
||||||
// Scroll to chart
|
// Scroll to chart
|
||||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -257,6 +316,7 @@ function showLiveChart(unitId) {
|
|||||||
|
|
||||||
function closeLiveChart() {
|
function closeLiveChart() {
|
||||||
stopDashboardStream();
|
stopDashboardStream();
|
||||||
|
stopPanelCachePolling();
|
||||||
document.getElementById('live-chart-panel').classList.add('hidden');
|
document.getElementById('live-chart-panel').classList.add('hidden');
|
||||||
window.selectedUnitId = null;
|
window.selectedUnitId = null;
|
||||||
}
|
}
|
||||||
@@ -270,17 +330,12 @@ function startDashboardStream() {
|
|||||||
window.dashboardWebSocket.close();
|
window.dashboardWebSocket.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset chart data
|
// The live WS takes over from the cache poller; keep the backfilled trail on
|
||||||
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
// the chart so the live frames continue the line instead of blanking it.
|
||||||
if (window.dashboardChart) {
|
stopPanelCachePolling();
|
||||||
window.dashboardChart.data.labels = [];
|
|
||||||
window.dashboardChart.data.datasets[0].data = [];
|
|
||||||
window.dashboardChart.data.datasets[1].data = [];
|
|
||||||
window.dashboardChart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`;
|
||||||
|
|
||||||
window.dashboardWebSocket = new WebSocket(wsUrl);
|
window.dashboardWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -293,6 +348,10 @@ function startDashboardStream() {
|
|||||||
window.dashboardWebSocket.onmessage = function(event) {
|
window.dashboardWebSocket.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
// /monitor sends keepalive 'heartbeat' frames (no metrics) and a per-frame
|
||||||
|
// 'feed_status'; skip heartbeats and offline frames so they don't blank the
|
||||||
|
// metrics or spike the chart with zeros.
|
||||||
|
if (data.heartbeat || data.feed_status === 'unreachable') return;
|
||||||
updateDashboardMetrics(data);
|
updateDashboardMetrics(data);
|
||||||
updateDashboardChart(data);
|
updateDashboardChart(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -316,37 +375,219 @@ function stopDashboardStream() {
|
|||||||
window.dashboardWebSocket.close();
|
window.dashboardWebSocket.close();
|
||||||
window.dashboardWebSocket = null;
|
window.dashboardWebSocket = null;
|
||||||
}
|
}
|
||||||
|
// Fall back to cache polling so the cards keep refreshing while the panel is open.
|
||||||
|
if (window.selectedUnitId && !document.getElementById('live-chart-panel').classList.contains('hidden')) {
|
||||||
|
startPanelCachePolling(window.selectedUnitId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardMetrics(data) {
|
function updateDashboardMetrics(data) {
|
||||||
document.getElementById('chart-lp').textContent = data.lp || '--';
|
document.getElementById('chart-lp').textContent = data.lp || '--';
|
||||||
document.getElementById('chart-leq').textContent = data.leq || '--';
|
document.getElementById('chart-leq').textContent = data.leq || '--';
|
||||||
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
||||||
document.getElementById('chart-lmin').textContent = data.lmin || '--';
|
// Guard: DRD stream frames omit percentiles, so only overwrite when present
|
||||||
document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
|
// (else the live stream blanks L1/L10 over the cached DOD snapshot values).
|
||||||
|
if (data.ln1 != null) document.getElementById('chart-ln1').textContent = data.ln1;
|
||||||
|
if (data.ln2 != null) document.getElementById('chart-ln2').textContent = data.ln2;
|
||||||
|
if (data.ln1_label) document.getElementById('chart-ln1-label').textContent = data.ln1_label;
|
||||||
|
if (data.ln2_label) document.getElementById('chart-ln2-label').textContent = data.ln2_label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardChart(data) {
|
function updateDashboardChart(data) {
|
||||||
|
const cd = window.dashboardChartData;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
cd.timestamps.push(now.toLocaleTimeString());
|
||||||
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
cd.lp.push(numOrNull(data.lp));
|
||||||
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
cd.leq.push(numOrNull(data.leq));
|
||||||
|
// /monitor (DOD) frames carry ln1/ln2; a DRD frame would omit them -> null gap.
|
||||||
|
cd.ln1.push(numOrNull(data.ln1));
|
||||||
|
cd.ln2.push(numOrNull(data.ln2));
|
||||||
|
|
||||||
// Keep only last 60 data points
|
// Keep a generous window (backfill seeds up to ~120 points from the 2h trail).
|
||||||
if (window.dashboardChartData.timestamps.length > 60) {
|
if (cd.timestamps.length > 600) {
|
||||||
window.dashboardChartData.timestamps.shift();
|
cd.timestamps.shift();
|
||||||
window.dashboardChartData.lp.shift();
|
cd.lp.shift();
|
||||||
window.dashboardChartData.leq.shift();
|
cd.leq.shift();
|
||||||
|
cd.ln1.shift();
|
||||||
|
cd.ln2.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.dashboardChart) {
|
if (window.dashboardChart) {
|
||||||
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
window.dashboardChart.data.labels = cd.timestamps;
|
||||||
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
window.dashboardChart.data.datasets[0].data = cd.lp;
|
||||||
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
window.dashboardChart.data.datasets[1].data = cd.leq;
|
||||||
|
window.dashboardChart.data.datasets[2].data = cd.ln1;
|
||||||
|
window.dashboardChart.data.datasets[3].data = cd.ln2;
|
||||||
window.dashboardChart.update('none');
|
window.dashboardChart.update('none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Cached-data panel population (no device hit) -----------------------
|
||||||
|
|
||||||
|
// Fill the KPI cards + measuring/freshness from the cached NL43Status snapshot.
|
||||||
|
async function prefillDashboardPanel(unitId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/status`);
|
||||||
|
if (!r.ok) { // 404 = device has never reported yet
|
||||||
|
setPanelStatus(null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = (await r.json()).data || {};
|
||||||
|
updateDashboardMetrics(d); // lp/leq/lmax/ln1/ln2 (ln guards keep cached percentiles)
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
setPanelStatus(measuring, d.last_seen);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Panel cache prefill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the chart from the downsampled DOD trail so it shows recent trend on open.
|
||||||
|
async function backfillDashboardChart(unitId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const readings = (await r.json()).readings || [];
|
||||||
|
const cd = window.dashboardChartData;
|
||||||
|
if (!cd) return;
|
||||||
|
for (const row of readings) {
|
||||||
|
// Trail timestamps are naive UTC; append 'Z' to render in local time
|
||||||
|
// consistently with the live frames (which use local Date.now()).
|
||||||
|
cd.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
||||||
|
cd.lp.push(numOrNull(row.lp));
|
||||||
|
cd.leq.push(numOrNull(row.leq));
|
||||||
|
cd.ln1.push(numOrNull(row.ln1));
|
||||||
|
cd.ln2.push(numOrNull(row.ln2));
|
||||||
|
}
|
||||||
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.data.labels = cd.timestamps;
|
||||||
|
window.dashboardChart.data.datasets[0].data = cd.lp;
|
||||||
|
window.dashboardChart.data.datasets[1].data = cd.leq;
|
||||||
|
window.dashboardChart.data.datasets[2].data = cd.ln1;
|
||||||
|
window.dashboardChart.data.datasets[3].data = cd.ln2;
|
||||||
|
window.dashboardChart.update('none');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Panel chart backfill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measuring badge + "as of <time> (Xm ago)" freshness, so a cached value is never
|
||||||
|
// mistaken for a live one. measuring: true | false | null(unknown).
|
||||||
|
function setPanelStatus(measuring, lastSeenIso) {
|
||||||
|
const badge = document.getElementById('panel-measuring-badge');
|
||||||
|
const fresh = document.getElementById('panel-freshness');
|
||||||
|
if (badge) {
|
||||||
|
if (measuring === null) {
|
||||||
|
badge.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full';
|
||||||
|
badge.textContent = '';
|
||||||
|
} else if (measuring) {
|
||||||
|
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
badge.textContent = '● Measuring';
|
||||||
|
} else {
|
||||||
|
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
badge.textContent = '■ Stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fresh) fresh.innerHTML = fmtFreshness(lastSeenIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human "x ago" with a staleness hint. Cached timestamps are naive UTC.
|
||||||
|
function fmtFreshness(lastSeenIso) {
|
||||||
|
if (!lastSeenIso) return '<span class="text-gray-400">no cached reading yet</span>';
|
||||||
|
const t = new Date(lastSeenIso.endsWith('Z') ? lastSeenIso : lastSeenIso + 'Z');
|
||||||
|
const secs = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
||||||
|
let ago, stale = false;
|
||||||
|
if (secs < 10) ago = 'just now';
|
||||||
|
else if (secs < 60) ago = secs + 's ago';
|
||||||
|
else if (secs < 3600) { ago = Math.round(secs / 60) + 'm ago'; stale = secs >= 300; }
|
||||||
|
else { ago = Math.round(secs / 3600) + 'h ago'; stale = true; }
|
||||||
|
const cls = stale ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400';
|
||||||
|
const tag = stale ? ' · cached' : '';
|
||||||
|
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${tag})</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache polling: refresh the cards from cache every 15s while the panel is open
|
||||||
|
// and not live-streaming. Pure cache reads — no device contention.
|
||||||
|
function startPanelCachePolling(unitId) {
|
||||||
|
stopPanelCachePolling();
|
||||||
|
window.panelCacheTimer = setInterval(() => {
|
||||||
|
if (window.selectedUnitId) prefillDashboardPanel(window.selectedUnitId);
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
function stopPanelCachePolling() {
|
||||||
|
if (window.panelCacheTimer) { clearInterval(window.panelCacheTimer); window.panelCacheTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- On-demand device refresh (the per-unit + panel refresh buttons) -----
|
||||||
|
|
||||||
|
// One bounded, user-initiated device read: hits the device, updates the cache,
|
||||||
|
// returns the fresh data. Throws on unreachable/disabled.
|
||||||
|
async function forceDeviceRead(unitId) {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/live`);
|
||||||
|
if (!r.ok) {
|
||||||
|
let detail = 'device unreachable';
|
||||||
|
try { detail = (await r.json()).detail || detail; } catch (e) {}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return (await r.json()).data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spinIcon(el, on) {
|
||||||
|
if (el) el.classList.toggle('animate-spin', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFreshReadToPanel(unitId, d) {
|
||||||
|
if (window.selectedUnitId !== unitId) return;
|
||||||
|
updateDashboardMetrics(d);
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
// The read just happened, so "now" is the accurate freshness even if the
|
||||||
|
// /live payload doesn't echo last_seen.
|
||||||
|
setPanelStatus(measuring, d.last_seen || new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-list row refresh button.
|
||||||
|
async function refreshSlmUnit(unitId, btn) {
|
||||||
|
const icon = btn ? btn.querySelector('svg') : null;
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
spinIcon(icon, true);
|
||||||
|
try {
|
||||||
|
const d = await forceDeviceRead(unitId);
|
||||||
|
applyFreshReadToPanel(unitId, d);
|
||||||
|
// Reload the list so the row's badge + last-check reflect the new cache.
|
||||||
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
|
||||||
|
htmx.trigger('#slm-devices-list', 'load');
|
||||||
|
}
|
||||||
|
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
|
||||||
|
else console.warn('refresh failed', e);
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
spinIcon(icon, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel header refresh button (refreshes the unit the panel is showing).
|
||||||
|
async function refreshDashboardPanel() {
|
||||||
|
const unitId = window.selectedUnitId;
|
||||||
|
if (!unitId) return;
|
||||||
|
const icon = document.getElementById('panel-refresh-icon');
|
||||||
|
spinIcon(icon, true);
|
||||||
|
try {
|
||||||
|
const d = await forceDeviceRead(unitId);
|
||||||
|
applyFreshReadToPanel(unitId, d);
|
||||||
|
updateDashboardChart(d); // append the fresh point to the chart
|
||||||
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
|
||||||
|
htmx.trigger('#slm-devices-list', 'load');
|
||||||
|
}
|
||||||
|
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
spinIcon(icon, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration modal - use unified SLM settings modal
|
// Configuration modal - use unified SLM settings modal
|
||||||
function openDeviceConfigModal(unitId) {
|
function openDeviceConfigModal(unitId) {
|
||||||
// Call the unified modal function from slm_settings_modal.html
|
// Call the unified modal function from slm_settings_modal.html
|
||||||
|
|||||||
@@ -14,6 +14,43 @@
|
|||||||
<!-- 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">
|
||||||
@@ -68,6 +105,24 @@
|
|||||||
</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">
|
||||||
@@ -86,6 +141,24 @@
|
|||||||
</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">
|
||||||
|
|||||||
+863
-43
File diff suppressed because it is too large
Load Diff
@@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="deleteAssignment('${esc(a.assignment_id)}', '${esc(a.unit_id)}', '${start} → ${end}')"
|
||||||
|
title="Delete this assignment record (for mis-clicks / duplicates)"
|
||||||
|
class="text-gray-400 hover:text-red-600 transition-colors p-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -608,6 +616,33 @@ function openAssignmentEditModal(encodedJson) {
|
|||||||
document.getElementById('assignment-edit-modal').classList.remove('hidden');
|
document.getElementById('assignment-edit-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAssignment(assignmentId, unitId, windowLabel) {
|
||||||
|
// For mis-clicks / accidental duplicate assignments. Backend refuses
|
||||||
|
// if there's a real recording session inside the window — those should
|
||||||
|
// go through Edit or Unassign instead.
|
||||||
|
const msg = `Delete this assignment?\n\n`
|
||||||
|
+ `Unit: ${unitId}\n`
|
||||||
|
+ `Window: ${windowLabel}\n\n`
|
||||||
|
+ `This is for assignments created in error. Events that fell `
|
||||||
|
+ `in this window will become unattributed. The unit's deployment `
|
||||||
|
+ `history will log the deletion for audit.\n\n`
|
||||||
|
+ `If the unit actually was deployed here, use Edit or Unassign instead.`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
await loadLocationEvents(); // Refresh stats + table without this assignment.
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to delete assignment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeAssignmentEditModal() {
|
function closeAssignmentEditModal() {
|
||||||
document.getElementById('assignment-edit-modal').classList.add('hidden');
|
document.getElementById('assignment-edit-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
@@ -957,4 +992,10 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
|||||||
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
{# 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