Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c38f6ec1e | |||
| 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 |
@@ -220,7 +220,6 @@ marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Seismo Fleet Manager
|
||||
# SQLite database files
|
||||
*.db
|
||||
@@ -228,6 +227,3 @@ __marimo__/
|
||||
/data/
|
||||
/data-dev/
|
||||
.aider*
|
||||
.aider*
|
||||
=======
|
||||
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027
|
||||
|
||||
+225
@@ -5,6 +5,231 @@ All notable changes to Terra-View will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Terra-View v0.12.1
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -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.
|
||||
+113
-3
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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 sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Optional
|
||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.12.1"
|
||||
VERSION = "0.13.3"
|
||||
if ENVIRONMENT == "development":
|
||||
_build = os.getenv("BUILD_NUMBER", "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
|
||||
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
|
||||
@app.middleware("http")
|
||||
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_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(modem_dashboard.router)
|
||||
|
||||
@@ -144,9 +163,18 @@ app.include_router(fleet_calendar.router)
|
||||
from backend.routers import deployments
|
||||
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
|
||||
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.calibration_sync import get_calibration_sync_scheduler
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
@@ -159,6 +187,10 @@ async def startup_event():
|
||||
await start_device_status_monitor()
|
||||
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")
|
||||
def shutdown_event():
|
||||
"""Clean up services on app shutdown"""
|
||||
@@ -170,6 +202,10 @@ def shutdown_event():
|
||||
stop_scheduler()
|
||||
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
|
||||
from backend import routes as legacy_routes
|
||||
@@ -377,10 +413,84 @@ async def project_detail_page(request: Request, project_id: str):
|
||||
"""Project detail dashboard"""
|
||||
return templates.TemplateResponse("projects/detail.html", {
|
||||
"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)
|
||||
async def nrl_detail_page(
|
||||
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()
|
||||
@@ -3,10 +3,19 @@
|
||||
Database migration: Add mic_unit_pref column to user_preferences.
|
||||
|
||||
Adds a single field controlling the mic channel's unit on the event-
|
||||
report waveform chart in the SFM event detail modal. "dBL" (default)
|
||||
or "psi". Peaks and KPI tiles elsewhere are always dBL regardless.
|
||||
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.
|
||||
|
||||
Idempotent — safe to re-run.
|
||||
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
|
||||
@@ -32,24 +41,33 @@ def migrate():
|
||||
cur.execute("PRAGMA table_info(user_preferences)")
|
||||
existing = {row[1] for row in cur.fetchall()}
|
||||
|
||||
if "mic_unit_pref" in existing:
|
||||
print("mic_unit_pref already exists — nothing to do.")
|
||||
conn.close()
|
||||
return
|
||||
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).")
|
||||
|
||||
cur.execute(
|
||||
"ALTER TABLE user_preferences "
|
||||
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'dBL'"
|
||||
)
|
||||
# Backfill the single row that should exist (id=1) to the default,
|
||||
# in case the column ends up NULL on existing rows.
|
||||
cur.execute(
|
||||
"UPDATE user_preferences SET mic_unit_pref = 'dBL' "
|
||||
"WHERE mic_unit_pref IS NULL"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Added mic_unit_pref to user_preferences (default 'dBL').")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+68
-2
@@ -136,8 +136,10 @@ class UserPreferences(Base):
|
||||
status_ok_threshold_hours = Column(Integer, default=12)
|
||||
status_pending_threshold_hours = Column(Integer, default=24)
|
||||
# Mic display units on the event-report waveform chart only — peaks
|
||||
# and KPI tiles elsewhere are always dBL. "dBL" (default) or "psi".
|
||||
mic_unit_pref = Column(String, default="dBL")
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -190,6 +192,7 @@ class Project(Base):
|
||||
|
||||
# Project metadata
|
||||
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_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||
start_date = Column(Date, nullable=True)
|
||||
@@ -216,6 +219,35 @@ class ProjectModule(Base):
|
||||
__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):
|
||||
"""
|
||||
Monitoring locations: generic location for monitoring activities.
|
||||
@@ -702,3 +734,37 @@ class PendingDeployment(Base):
|
||||
|
||||
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
|
||||
from backend.database import get_db
|
||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||
from backend.services.unit_location import get_active_location
|
||||
|
||||
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)
|
||||
time_ago = f"{days}d ago"
|
||||
|
||||
loc = get_active_location(db, emitter.id) if roster_unit else None
|
||||
call_in = {
|
||||
"unit_id": emitter.id,
|
||||
"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",
|
||||
"deployed": roster_unit.deployed if roster_unit else False,
|
||||
"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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
||||
# Last seen from emitter
|
||||
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 {
|
||||
"id": u.id,
|
||||
"unit_type": u.unit_type,
|
||||
"deployed": u.deployed,
|
||||
"out_for_calibration": u.out_for_calibration or False,
|
||||
"note": u.note or "",
|
||||
"project_id": u.project_id or "",
|
||||
"address": u.address or u.location or "",
|
||||
"coordinates": u.coordinates or "",
|
||||
"project_id": (loc or {}).get("project_id") or u.project_id or "",
|
||||
"address": (loc or {}).get("address") or "",
|
||||
"coordinates": (loc or {}).get("coordinates") or "",
|
||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.services.unit_location import get_active_location
|
||||
from backend.templates_config import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -85,8 +86,7 @@ async def get_modem_units(
|
||||
(RosterUnit.id.ilike(search_term)) |
|
||||
(RosterUnit.ip_address.ilike(search_term)) |
|
||||
(RosterUnit.hardware_model.ilike(search_term)) |
|
||||
(RosterUnit.phone_number.ilike(search_term)) |
|
||||
(RosterUnit.location.ilike(search_term))
|
||||
(RosterUnit.phone_number.ilike(search_term))
|
||||
)
|
||||
|
||||
modems = query.order_by(
|
||||
@@ -128,6 +128,8 @@ async def get_modem_units(
|
||||
if filter_status and status != filter_status:
|
||||
continue
|
||||
|
||||
# Inherit location from the paired device's active assignment.
|
||||
loc = get_active_location(db, modem.id) if paired else None
|
||||
modem_list.append({
|
||||
"id": modem.id,
|
||||
"ip_address": modem.ip_address,
|
||||
@@ -135,8 +137,8 @@ async def get_modem_units(
|
||||
"hardware_model": modem.hardware_model,
|
||||
"deployed": modem.deployed,
|
||||
"retired": modem.retired,
|
||||
"location": modem.location,
|
||||
"project_id": modem.project_id,
|
||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||
"project_id": (loc or {}).get("project_id") or modem.project_id,
|
||||
"paired_device": paired,
|
||||
"status": status
|
||||
})
|
||||
@@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
|
||||
).first()
|
||||
|
||||
if device:
|
||||
loc = get_active_location(db, device.id)
|
||||
return {
|
||||
"paired": True,
|
||||
"device": {
|
||||
"id": device.id,
|
||||
"device_type": device.device_type,
|
||||
"deployed": device.deployed,
|
||||
"project_id": device.project_id,
|
||||
"location": device.location or device.address
|
||||
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,8 +317,6 @@ async def get_pairable_devices(
|
||||
query = query.filter(
|
||||
(RosterUnit.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))
|
||||
)
|
||||
|
||||
@@ -338,12 +339,13 @@ async def get_pairable_devices(
|
||||
if hide_paired and is_paired_to_other:
|
||||
continue
|
||||
|
||||
loc = get_active_location(db, device.id)
|
||||
device_list.append({
|
||||
"id": device.id,
|
||||
"device_type": device.device_type,
|
||||
"deployed": device.deployed,
|
||||
"project_id": device.project_id,
|
||||
"location": device.location or device.address,
|
||||
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||
"note": device.note,
|
||||
"paired_modem_id": device.deployed_with_modem_id,
|
||||
"is_paired_to_this": is_paired_to_this,
|
||||
|
||||
@@ -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
|
||||
@@ -1483,11 +1483,13 @@ async def get_available_units(
|
||||
).distinct().all()
|
||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||
|
||||
# These units have no active assignment by definition, so there's no
|
||||
# current location to show — leave the field empty.
|
||||
available_units = [
|
||||
{
|
||||
"id": unit.id,
|
||||
"device_type": unit.device_type,
|
||||
"location": unit.address or unit.location,
|
||||
"location": "",
|
||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||
"deployed": bool(unit.deployed),
|
||||
}
|
||||
@@ -1710,6 +1712,19 @@ def _parse_rnh(content: bytes) -> dict:
|
||||
result["stop_time_str"] = value
|
||||
elif key == "Total Measurement Time":
|
||||
result["total_time_str"] = value
|
||||
elif key == "Frequency Weighting (Main)":
|
||||
result["frequency_weighting"] = value
|
||||
elif key == "Time Weighting (Main)":
|
||||
result["time_weighting"] = value
|
||||
elif key == "Leq Calculation Interval":
|
||||
result["leq_interval"] = value
|
||||
elif key.startswith("Percentile "):
|
||||
# e.g. "Percentile 4,90.0" → percentiles["4"] = "90.0".
|
||||
# Lets the report label the LN slots (here LN4 = L90) from the
|
||||
# device's own config instead of hardcoding which slot is which —
|
||||
# the percentile assignment is reconfigurable per job.
|
||||
slot = key[len("Percentile "):].strip()
|
||||
result.setdefault("percentiles", {})[slot] = value
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@@ -1738,6 +1753,270 @@ def _classify_file(filename: str) -> str:
|
||||
return "data"
|
||||
|
||||
|
||||
def _is_wanted_nrl_file(fname: str) -> bool:
|
||||
"""Keep only the files an NRL ingest cares about: .rnh metadata + the
|
||||
averaged Leq .rnd. Drops the 1-second _Lp_ files and everything else.
|
||||
|
||||
- NL-43 writes two .rnd types: _Leq_ (15-min averages, wanted) and
|
||||
_Lp_ (1-second granular, skipped).
|
||||
- AU2 (NL-23/older Rion) writes a single Au2_####.rnd — always keep.
|
||||
|
||||
Note this is purely about which *files* to store, not which *metrics* to
|
||||
report: the kept Leq file carries every column (Leq, Lmax, L1/L10/L50/
|
||||
L90/L95, Lpeak, …), so the report layer can select any metric later.
|
||||
"""
|
||||
n = fname.lower()
|
||||
if n.endswith(".rnh"):
|
||||
return True
|
||||
if n.endswith(".rnd"):
|
||||
if "_leq_" in n: # NL-43 Leq file
|
||||
return True
|
||||
if n.startswith("au2_"): # AU2 format (NL-23) — Leq equivalent
|
||||
return True
|
||||
if "_lp" not in n and "_leq_" not in n:
|
||||
# Unknown .rnd format — include it so we don't silently drop data
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class IngestError(Exception):
|
||||
"""Raised when an NRL upload/ZIP has no usable data or an invalid target.
|
||||
|
||||
Kept HTTP-agnostic so the ingest core can be driven programmatically (the
|
||||
scheduled FTP pull) as well as from the HTTP upload endpoint. Callers
|
||||
translate it: the endpoint → HTTP 400, the scheduler → logged failure.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def _find_existing_session(
|
||||
db: Session,
|
||||
location_id: str,
|
||||
store_name: str,
|
||||
started_at,
|
||||
start_time_str: str,
|
||||
):
|
||||
"""Return an already-ingested session for this location that represents the
|
||||
same measurement, or None.
|
||||
|
||||
Used to make FTP re-pulls idempotent: a daily cycle closes one Auto_####
|
||||
folder per day, so a session is uniquely identified within a location by
|
||||
(store_name + measurement start time). Store names recycle across jobs, so
|
||||
we always match on start time too.
|
||||
"""
|
||||
if not store_name and not started_at:
|
||||
return None
|
||||
candidates = db.query(MonitoringSession).filter(
|
||||
MonitoringSession.location_id == location_id,
|
||||
MonitoringSession.session_type == "sound",
|
||||
).all()
|
||||
for s in candidates:
|
||||
try:
|
||||
meta = json.loads(s.session_metadata or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
if store_name and meta.get("store_name") != store_name:
|
||||
continue
|
||||
# Same store_name — confirm it's the same measurement by start time.
|
||||
if start_time_str and meta.get("start_time_str") == start_time_str:
|
||||
return s
|
||||
if not meta.get("start_time_str") and started_at and s.started_at == started_at:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _ingest_file_entries(
|
||||
location: MonitoringLocation,
|
||||
file_entries: list[tuple[str, bytes]],
|
||||
db: Session,
|
||||
*,
|
||||
source: str = "manual_upload",
|
||||
dedupe: bool = False,
|
||||
) -> dict:
|
||||
"""Core NRL ingest, shared by the HTTP upload and the programmatic FTP pull.
|
||||
|
||||
Takes already-normalized (filename, bytes) entries, keeps the wanted files,
|
||||
parses the .rnh, and creates a MonitoringSession + DataFile rows under the
|
||||
location's project. Metric-agnostic: the full Leq file is written to disk
|
||||
and every column preserved; metric selection happens in the report layer.
|
||||
|
||||
Raises IngestError if no usable files are present.
|
||||
"""
|
||||
# --- Filter to the files we keep (.rnh + Leq .rnd) ---
|
||||
file_entries = [(f, b) for f, b in file_entries if _is_wanted_nrl_file(f)]
|
||||
if not file_entries:
|
||||
raise IngestError(
|
||||
"No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files."
|
||||
)
|
||||
|
||||
# --- Parse .rnh metadata (first one wins) ---
|
||||
rnh_meta = {}
|
||||
for fname, fbytes in file_entries:
|
||||
if fname.lower().endswith(".rnh"):
|
||||
rnh_meta = _parse_rnh(fbytes)
|
||||
break
|
||||
|
||||
# RNH stores local time (no UTC offset). Use local for period/label, then
|
||||
# convert to UTC for storage so the local_datetime filter displays correctly.
|
||||
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||
started_at = local_to_utc(started_at_local)
|
||||
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
|
||||
duration_seconds = (
|
||||
int((stopped_at - started_at).total_seconds())
|
||||
if (started_at and stopped_at) else None
|
||||
)
|
||||
|
||||
store_name = rnh_meta.get("store_name", "")
|
||||
serial_number = rnh_meta.get("serial_number", "")
|
||||
index_number = rnh_meta.get("index_number", "")
|
||||
start_time_str = rnh_meta.get("start_time_str", "")
|
||||
|
||||
# --- Dedupe: skip if this exact measurement is already ingested ---
|
||||
if dedupe:
|
||||
existing = _find_existing_session(db, location.id, store_name, started_at, start_time_str)
|
||||
if existing:
|
||||
return {
|
||||
"success": True,
|
||||
"deduped": True,
|
||||
"session_id": existing.id,
|
||||
"files_imported": 0,
|
||||
"leq_files": 0,
|
||||
"lp_files": 0,
|
||||
"metadata_files": 0,
|
||||
"store_name": store_name,
|
||||
"started_at": started_at.isoformat() if started_at else None,
|
||||
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||
}
|
||||
|
||||
# --- Create MonitoringSession (local times drive period/label) ---
|
||||
period_type = _derive_period_type(started_at_local) if started_at_local else None
|
||||
session_label = (
|
||||
_build_session_label(started_at_local, location.name, period_type)
|
||||
if started_at_local else None
|
||||
)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
monitoring_session = MonitoringSession(
|
||||
id=session_id,
|
||||
project_id=location.project_id,
|
||||
location_id=location.id,
|
||||
unit_id=None,
|
||||
session_type="sound",
|
||||
started_at=started_at,
|
||||
stopped_at=stopped_at,
|
||||
duration_seconds=duration_seconds,
|
||||
status="completed",
|
||||
session_label=session_label,
|
||||
period_type=period_type,
|
||||
session_metadata=json.dumps({
|
||||
"source": source,
|
||||
"store_name": store_name,
|
||||
"serial_number": serial_number,
|
||||
"index_number": index_number,
|
||||
"start_time_str": start_time_str,
|
||||
# Captured from the .rnh so the report can label metrics from the
|
||||
# device's own config (which LN slot is L90, the weightings, etc.).
|
||||
"percentiles": rnh_meta.get("percentiles", {}),
|
||||
"frequency_weighting": rnh_meta.get("frequency_weighting", ""),
|
||||
"time_weighting": rnh_meta.get("time_weighting", ""),
|
||||
"leq_interval": rnh_meta.get("leq_interval", ""),
|
||||
}),
|
||||
)
|
||||
db.add(monitoring_session)
|
||||
db.commit()
|
||||
db.refresh(monitoring_session)
|
||||
|
||||
# --- Write files to disk + create DataFile records ---
|
||||
output_dir = Path("data/Projects") / location.project_id / session_id
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
leq_count = lp_count = metadata_count = files_imported = 0
|
||||
for fname, fbytes in file_entries:
|
||||
fname_lower = fname.lower()
|
||||
if fname_lower.endswith(".rnd"):
|
||||
if "_leq_" in fname_lower:
|
||||
leq_count += 1
|
||||
elif "_lp" in fname_lower:
|
||||
lp_count += 1
|
||||
elif fname_lower.endswith(".rnh"):
|
||||
metadata_count += 1
|
||||
|
||||
dest = output_dir / fname
|
||||
dest.write_bytes(fbytes)
|
||||
checksum = hashlib.sha256(fbytes).hexdigest()
|
||||
rel_path = str(dest.relative_to("data"))
|
||||
|
||||
db.add(DataFile(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
file_path=rel_path,
|
||||
file_type=_classify_file(fname),
|
||||
file_size_bytes=len(fbytes),
|
||||
downloaded_at=datetime.utcnow(),
|
||||
checksum=checksum,
|
||||
file_metadata=json.dumps({
|
||||
"source": source,
|
||||
"original_filename": fname,
|
||||
"store_name": store_name,
|
||||
}),
|
||||
))
|
||||
files_imported += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"deduped": False,
|
||||
"session_id": session_id,
|
||||
"files_imported": files_imported,
|
||||
"leq_files": leq_count,
|
||||
"lp_files": lp_count,
|
||||
"metadata_files": metadata_count,
|
||||
"store_name": store_name,
|
||||
"started_at": started_at.isoformat() if started_at else None,
|
||||
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||
}
|
||||
|
||||
|
||||
def ingest_nrl_zip(
|
||||
location_id: str,
|
||||
zip_bytes: bytes,
|
||||
db: Session,
|
||||
*,
|
||||
source: str = "ftp_pull",
|
||||
dedupe: bool = True,
|
||||
) -> dict:
|
||||
"""Programmatically ingest an Auto_#### ZIP (e.g. a scheduled FTP pull).
|
||||
|
||||
Extracts the ZIP (flattening any nested Auto_Leq/Auto_Lp_ folders), keeps
|
||||
the .rnh + Leq .rnd, parses the header, and creates a MonitoringSession +
|
||||
DataFile rows for `location_id`. Defaults to dedupe=True so repeated daily
|
||||
pulls of the same closed folder don't create duplicate sessions.
|
||||
|
||||
Returns the same dict shape as the HTTP upload, plus a `deduped` flag.
|
||||
Raises IngestError on a bad ZIP, no usable files, or unknown location.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||||
if not location:
|
||||
raise IngestError(f"Location {location_id} not found")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
file_entries: list[tuple[str, bytes]] = []
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = Path(info.filename).name # strip nested folder paths
|
||||
if not name:
|
||||
continue
|
||||
file_entries.append((name, zf.read(info)))
|
||||
except zipfile.BadZipFile:
|
||||
raise IngestError("Downloaded data is not a valid ZIP archive.")
|
||||
|
||||
return _ingest_file_entries(location, file_entries, db, source=source, dedupe=dedupe)
|
||||
|
||||
|
||||
@router.post("/nrl/{location_id}/upload-data")
|
||||
async def upload_nrl_data(
|
||||
project_id: str,
|
||||
@@ -1752,11 +2031,13 @@ async def upload_nrl_data(
|
||||
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
|
||||
- Multiple .rnd / .rnh files selected directly from the SD card folder
|
||||
|
||||
Creates a MonitoringSession from .rnh metadata and DataFile records
|
||||
for each measurement file. No unit assignment required.
|
||||
Normalizes the upload to (filename, bytes) entries, then hands off to the
|
||||
shared ingest core (`_ingest_file_entries`) — the same path the scheduled
|
||||
FTP pull uses via `ingest_nrl_zip`. Creates a MonitoringSession from the
|
||||
.rnh metadata and DataFile records for each measurement file. No unit
|
||||
assignment required. dedupe=False here preserves the prior manual-upload
|
||||
behaviour (re-uploading creates a fresh session).
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify project and location exist
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
_require_module(project, "sound_monitoring", db)
|
||||
@@ -1767,7 +2048,7 @@ async def upload_nrl_data(
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
# --- Step 1: Normalize to (filename, bytes) list ---
|
||||
# --- Normalize upload to (filename, bytes) entries ---
|
||||
file_entries: list[tuple[str, bytes]] = []
|
||||
|
||||
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
|
||||
@@ -1791,145 +2072,11 @@ async def upload_nrl_data(
|
||||
if not file_entries:
|
||||
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
||||
|
||||
# --- Step 1b: Filter to only relevant files ---
|
||||
# Keep: .rnh (metadata) and measurement .rnd files
|
||||
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
|
||||
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
|
||||
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
|
||||
def _is_wanted(fname: str) -> bool:
|
||||
n = fname.lower()
|
||||
if n.endswith(".rnh"):
|
||||
return True
|
||||
if n.endswith(".rnd"):
|
||||
if "_leq_" in n: # NL-43 Leq file
|
||||
return True
|
||||
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
|
||||
return True
|
||||
if "_lp" not in n and "_leq_" not in n:
|
||||
# Unknown .rnd format — include it so we don't silently drop data
|
||||
return True
|
||||
return False
|
||||
|
||||
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
|
||||
|
||||
if not file_entries:
|
||||
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
|
||||
|
||||
# --- Step 2: Find and parse .rnh metadata ---
|
||||
rnh_meta = {}
|
||||
for fname, fbytes in file_entries:
|
||||
if fname.lower().endswith(".rnh"):
|
||||
rnh_meta = _parse_rnh(fbytes)
|
||||
break
|
||||
|
||||
# RNH files store local time (no UTC offset). Use local values for period
|
||||
# classification / label generation, then convert to UTC for DB storage so
|
||||
# the local_datetime Jinja filter displays the correct time.
|
||||
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||
|
||||
started_at = local_to_utc(started_at_local)
|
||||
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
|
||||
|
||||
duration_seconds = None
|
||||
if started_at and stopped_at:
|
||||
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||
|
||||
store_name = rnh_meta.get("store_name", "")
|
||||
serial_number = rnh_meta.get("serial_number", "")
|
||||
index_number = rnh_meta.get("index_number", "")
|
||||
|
||||
# --- Step 3: Create MonitoringSession ---
|
||||
# Use local times for period/label so classification reflects the clock at the site.
|
||||
period_type = _derive_period_type(started_at_local) if started_at_local else None
|
||||
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
monitoring_session = MonitoringSession(
|
||||
id=session_id,
|
||||
project_id=project_id,
|
||||
location_id=location_id,
|
||||
unit_id=None,
|
||||
session_type="sound",
|
||||
started_at=started_at,
|
||||
stopped_at=stopped_at,
|
||||
duration_seconds=duration_seconds,
|
||||
status="completed",
|
||||
session_label=session_label,
|
||||
period_type=period_type,
|
||||
session_metadata=json.dumps({
|
||||
"source": "manual_upload",
|
||||
"store_name": store_name,
|
||||
"serial_number": serial_number,
|
||||
"index_number": index_number,
|
||||
}),
|
||||
)
|
||||
db.add(monitoring_session)
|
||||
db.commit()
|
||||
db.refresh(monitoring_session)
|
||||
|
||||
# --- Step 4: Write files to disk and create DataFile records ---
|
||||
output_dir = Path("data/Projects") / project_id / session_id
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
leq_count = 0
|
||||
lp_count = 0
|
||||
metadata_count = 0
|
||||
files_imported = 0
|
||||
|
||||
for fname, fbytes in file_entries:
|
||||
file_type = _classify_file(fname)
|
||||
fname_lower = fname.lower()
|
||||
|
||||
# Track counts for summary
|
||||
if fname_lower.endswith(".rnd"):
|
||||
if "_leq_" in fname_lower:
|
||||
leq_count += 1
|
||||
elif "_lp" in fname_lower:
|
||||
lp_count += 1
|
||||
elif fname_lower.endswith(".rnh"):
|
||||
metadata_count += 1
|
||||
|
||||
# Write to disk
|
||||
dest = output_dir / fname
|
||||
dest.write_bytes(fbytes)
|
||||
|
||||
# Compute checksum
|
||||
checksum = hashlib.sha256(fbytes).hexdigest()
|
||||
|
||||
# Store relative path from data/ dir
|
||||
rel_path = str(dest.relative_to("data"))
|
||||
|
||||
data_file = DataFile(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
file_path=rel_path,
|
||||
file_type=file_type,
|
||||
file_size_bytes=len(fbytes),
|
||||
downloaded_at=datetime.utcnow(),
|
||||
checksum=checksum,
|
||||
file_metadata=json.dumps({
|
||||
"source": "manual_upload",
|
||||
"original_filename": fname,
|
||||
"store_name": store_name,
|
||||
}),
|
||||
)
|
||||
db.add(data_file)
|
||||
files_imported += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"files_imported": files_imported,
|
||||
"leq_files": leq_count,
|
||||
"lp_files": lp_count,
|
||||
"metadata_files": metadata_count,
|
||||
"store_name": store_name,
|
||||
"started_at": started_at.isoformat() if started_at else None,
|
||||
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||
}
|
||||
# --- Hand off to the shared ingest core ---
|
||||
try:
|
||||
return _ingest_file_entries(location, file_entries, db, source="manual_upload", dedupe=False)
|
||||
except IngestError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
import uuid
|
||||
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"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -182,9 +183,6 @@ async def add_roster_unit(
|
||||
out_for_calibration: str = Form(None),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
address: str = Form(None),
|
||||
coordinates: str = Form(None),
|
||||
# Seismograph-specific fields
|
||||
last_calibrated: 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,
|
||||
note=note,
|
||||
project_id=project_id,
|
||||
location=location,
|
||||
address=address,
|
||||
coordinates=coordinates,
|
||||
last_updated=datetime.utcnow(),
|
||||
# Seismograph-specific fields
|
||||
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,
|
||||
)
|
||||
|
||||
# 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:
|
||||
modem = db.query(RosterUnit).filter(
|
||||
RosterUnit.id == deployed_with_modem_id,
|
||||
RosterUnit.device_type == "modem"
|
||||
).first()
|
||||
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:
|
||||
unit.project_id = modem.project_id
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
active_loc = get_active_location(db, unit_id)
|
||||
|
||||
return {
|
||||
"id": unit.id,
|
||||
"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 "",
|
||||
"note": unit.note or "",
|
||||
"project_id": unit.project_id or "",
|
||||
"location": unit.location or "",
|
||||
"address": unit.address or "",
|
||||
"coordinates": unit.coordinates or "",
|
||||
"active_location": active_loc,
|
||||
# Convenience fields so the unit-detail page can read the same shape
|
||||
# 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 "",
|
||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||
"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),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
address: str = Form(None),
|
||||
coordinates: str = Form(None),
|
||||
# Seismograph-specific fields
|
||||
last_calibrated: str = Form(None),
|
||||
next_calibration_due: str = Form(None),
|
||||
@@ -565,8 +557,6 @@ async def edit_roster_unit(
|
||||
cascade_deployed: str = Form(None),
|
||||
cascade_retired: str = Form(None),
|
||||
cascade_project: str = Form(None),
|
||||
cascade_location: str = Form(None),
|
||||
cascade_coordinates: str = Form(None),
|
||||
cascade_note: str = Form(None),
|
||||
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.note = note
|
||||
unit.project_id = project_id
|
||||
unit.location = location
|
||||
unit.address = address
|
||||
unit.coordinates = coordinates
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Seismograph-specific fields
|
||||
@@ -630,20 +617,15 @@ async def edit_roster_unit(
|
||||
unit.next_calibration_due = next_cal_date
|
||||
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:
|
||||
modem = db.query(RosterUnit).filter(
|
||||
RosterUnit.id == deployed_with_modem_id,
|
||||
RosterUnit.device_type == "modem"
|
||||
).first()
|
||||
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:
|
||||
unit.project_id = modem.project_id
|
||||
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",
|
||||
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
|
||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||
old_paired_note = paired_unit.note
|
||||
@@ -1011,9 +973,8 @@ async def import_csv(
|
||||
- retired: Boolean
|
||||
- note: Notes about the unit
|
||||
- project_id: Project identifier
|
||||
- location: Location description
|
||||
- address: Street address
|
||||
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
||||
(Location / address / coordinates are not roster fields anymore — they
|
||||
live on the MonitoringLocation a unit is assigned to.)
|
||||
|
||||
Seismograph-specific:
|
||||
- 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.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.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()
|
||||
|
||||
# Seismograph-specific fields
|
||||
@@ -1194,9 +1152,6 @@ async def import_csv(
|
||||
retired=_parse_bool(row.get('retired', '')),
|
||||
note=_get_csv_value(row, 'note', ''),
|
||||
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(),
|
||||
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
||||
last_calibrated=last_cal,
|
||||
|
||||
+13
-11
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||
from backend.services.database_backup import DatabaseBackupService
|
||||
from backend.services.unit_location import bulk_active_locations
|
||||
|
||||
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"""
|
||||
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()
|
||||
fieldnames = [
|
||||
'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',
|
||||
'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',
|
||||
'note': unit.note 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 '',
|
||||
'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 '',
|
||||
@@ -82,6 +83,7 @@ def get_table_stats(db: Session = Depends(get_db)):
|
||||
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||
"""Get all roster units for management table"""
|
||||
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||
active_locs = bulk_active_locations(db, units)
|
||||
|
||||
return [{
|
||||
"id": unit.id,
|
||||
@@ -90,10 +92,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
||||
"deployed": unit.deployed,
|
||||
"retired": unit.retired,
|
||||
"note": unit.note or "",
|
||||
"project_id": unit.project_id or "",
|
||||
"location": unit.location or "",
|
||||
"address": unit.address or "",
|
||||
"coordinates": unit.coordinates or "",
|
||||
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
|
||||
"address": (active_locs.get(unit.id) or {}).get("address") or "",
|
||||
"coordinates": (active_locs.get(unit.id) or {}).get("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,
|
||||
"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 "",
|
||||
@@ -294,7 +296,7 @@ def get_preferences(db: Session = Depends(get_db)):
|
||||
"calibration_warning_days": prefs.calibration_warning_days,
|
||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
|
||||
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||
}
|
||||
|
||||
@@ -336,7 +338,7 @@ def update_preferences(
|
||||
"calibration_warning_days": prefs.calibration_warning_days,
|
||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
|
||||
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
||||
"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)
|
||||
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.measurement_state = None
|
||||
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
|
||||
|
||||
if include_measurement:
|
||||
async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
|
||||
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:
|
||||
# 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:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster")
|
||||
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):
|
||||
if isinstance(state, Exception):
|
||||
unit.measurement_state = None
|
||||
else:
|
||||
unit.measurement_state = state
|
||||
# "Recent" = the monitor has a fresh successful read. last_seen only advances
|
||||
# on a successful poll, so staleness == the device isn't being reached.
|
||||
recent_cutoff = datetime.utcnow() - timedelta(minutes=5)
|
||||
for unit in units:
|
||||
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", {
|
||||
"request": request,
|
||||
@@ -157,25 +171,18 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
||||
is_measuring = False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
state_data = state_response.json()
|
||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||
is_measuring = state_data.get("is_measuring", False)
|
||||
|
||||
# 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", {})
|
||||
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor
|
||||
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we
|
||||
# no longer fire Measure? + a fresh DOD read at the device on every command-
|
||||
# center load (which competed with DOD polling for the single connection).
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||
if r.status_code == 200:
|
||||
current_status = r.json().get("data", {})
|
||||
measurement_state = current_status.get("measurement_state")
|
||||
is_measuring = measurement_state in ("Start", "Measure")
|
||||
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", {
|
||||
"request": request,
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.services.unit_location import get_active_location
|
||||
from backend.templates_config import templates
|
||||
|
||||
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:
|
||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||
|
||||
loc = get_active_location(db, unit_id)
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"device_type": "slm",
|
||||
"deployed": unit.deployed,
|
||||
"model": unit.slm_model or "NL-43",
|
||||
"location": unit.address or unit.location,
|
||||
"coordinates": unit.coordinates,
|
||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||
"coordinates": (loc or {}).get("coordinates") or "",
|
||||
"note": unit.note,
|
||||
"status": status_data,
|
||||
"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)")
|
||||
|
||||
|
||||
@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)
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
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.services.snapshot import emit_status_snapshot
|
||||
from backend.services.unit_location import get_active_location
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["units"])
|
||||
@@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"])
|
||||
@router.get("/unit/{unit_id}")
|
||||
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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
unit_data = snapshot["units"][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"})
|
||||
active_loc = get_active_location(db, unit_id)
|
||||
|
||||
return {
|
||||
"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", ""),
|
||||
"deployed": unit_data["deployed"],
|
||||
"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)):
|
||||
"""
|
||||
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()
|
||||
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||
|
||||
active_loc = get_active_location(db, unit_id)
|
||||
|
||||
return {
|
||||
"id": unit.id,
|
||||
"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,
|
||||
"retired": unit.retired,
|
||||
"note": unit.note,
|
||||
"location": unit.location,
|
||||
"address": unit.address,
|
||||
"coordinates": unit.coordinates,
|
||||
"active_location": active_loc,
|
||||
"address": (active_loc or {}).get("address") or "",
|
||||
"coordinates": (active_loc or {}).get("coordinates") or "",
|
||||
"slm_host": unit.slm_host,
|
||||
"slm_tcp_port": unit.slm_tcp_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,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
|
||||
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)
|
||||
now = datetime.utcnow()
|
||||
if (now - last_generation_check).total_seconds() >= 3600:
|
||||
@@ -633,6 +636,46 @@ class SchedulerService:
|
||||
)
|
||||
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
|
||||
logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...")
|
||||
await asyncio.sleep(30)
|
||||
@@ -667,6 +710,33 @@ class SchedulerService:
|
||||
|
||||
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:
|
||||
logger.error(f"[CYCLE] Start failed: {e}")
|
||||
result["steps"]["start"] = {"success": False, "error": str(e)}
|
||||
@@ -689,6 +759,37 @@ class SchedulerService:
|
||||
logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===")
|
||||
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
|
||||
# ========================================================================
|
||||
@@ -782,6 +883,92 @@ class SchedulerService:
|
||||
|
||||
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)
|
||||
# ========================================================================
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db_session
|
||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||
from backend.services.unit_location import bulk_active_locations
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,6 +138,10 @@ def emit_status_snapshot():
|
||||
emitters = {e.id: e for e in db.query(Emitter).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
|
||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||
# 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,
|
||||
"phone_number": r.phone_number,
|
||||
"hardware_model": r.hardware_model,
|
||||
# Location for mapping
|
||||
"location": r.location or "",
|
||||
"address": r.address or "",
|
||||
"coordinates": r.coordinates or "",
|
||||
# Location for mapping — sourced from active UnitAssignment
|
||||
# → MonitoringLocation. Empty for benched / unassigned.
|
||||
"address": (active_locs.get(unit_id) or {}).get("address") 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 ---
|
||||
@@ -267,10 +275,12 @@ def emit_status_snapshot():
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
# Location fields
|
||||
"location": "",
|
||||
# Location fields — unknown units have no assignment
|
||||
"address": "",
|
||||
"coordinates": "",
|
||||
"location_name": "",
|
||||
"project_id": "",
|
||||
"location_id": "",
|
||||
}
|
||||
|
||||
# --- Derive modem status from paired devices ---
|
||||
@@ -301,6 +311,11 @@ def emit_status_snapshot():
|
||||
unit_data["last"] = paired_unit.get("last")
|
||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||
unit_data["derived_from"] = paired_unit_id
|
||||
# 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
|
||||
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
|
||||
@@ -46,7 +46,7 @@
|
||||
const MIC_DBL_FLOOR = 60;
|
||||
|
||||
let _charts = {}; // ch → Chart instance
|
||||
let _micUnitPref = 'dBL'; // refreshed via fetch on first chart render
|
||||
let _micUnitPref = 'psi'; // refreshed via fetch on first chart render
|
||||
let _micUnitPrefLoaded = false; // one-shot fetch guard
|
||||
|
||||
function _esc(s) {
|
||||
@@ -294,10 +294,10 @@
|
||||
const r = await fetch('/api/settings/preferences');
|
||||
if (r.ok) {
|
||||
const prefs = await r.json();
|
||||
_micUnitPref = prefs.mic_unit_pref === 'psi' ? 'psi' : 'dBL';
|
||||
_micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi';
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error → silent fall back to default 'dBL'.
|
||||
// Network error → silent fall back to default 'psi'.
|
||||
}
|
||||
_micUnitPrefLoaded = true;
|
||||
return _micUnitPref;
|
||||
@@ -647,7 +647,9 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
||||
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span></div>
|
||||
<div title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that).">
|
||||
<span class="text-gray-500">Time received</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span>
|
||||
</div>
|
||||
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
+11
-2
@@ -1,18 +1,27 @@
|
||||
/* Service Worker for Seismo Fleet Manager PWA */
|
||||
/* 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 DYNAMIC_CACHE = `sfm-dynamic-${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 = [
|
||||
'/',
|
||||
'/static/style.css',
|
||||
'/static/mobile.css',
|
||||
'/static/mobile.js',
|
||||
'/static/offline-db.js',
|
||||
'/static/event-modal.js',
|
||||
'/static/manifest.json',
|
||||
'https://cdn.tailwindcss.com',
|
||||
'https://unpkg.com/htmx.org@1.9.10',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
|
||||
terra-view:
|
||||
web-app:
|
||||
build: .
|
||||
ports:
|
||||
- "8001:8001"
|
||||
|
||||
@@ -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
|
||||
openpyxl==3.1.2
|
||||
rapidfuzz==3.10.1
|
||||
schedule==1.2.2
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
</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 -->
|
||||
<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>
|
||||
@@ -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();
|
||||
setInterval(loadSlmmOverview, 30000);
|
||||
loadMonitors();
|
||||
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+86
-51
@@ -150,46 +150,55 @@ setInterval(_refreshPendingDeployBanner, 30000);
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||
</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="space-y-4 card-content" id="fleet-summary-content">
|
||||
<!-- Seismographs -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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 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">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||
</div>
|
||||
</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">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 items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||
@@ -628,9 +637,14 @@ function updateFleetMapFiltered(allUnits) {
|
||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||
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 || {})
|
||||
.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) {
|
||||
return;
|
||||
@@ -672,10 +686,12 @@ function updateFleetMapFiltered(allUnits) {
|
||||
// Popup with device type
|
||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
||||
|
||||
const locName = unit.location_name || '';
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg">${id}</h3>
|
||||
<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>
|
||||
${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>
|
||||
@@ -783,32 +799,51 @@ function updateDashboard(event) {
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
// ===== Fleet summary numbers (always unfiltered) =====
|
||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
|
||||
// Deployed = unit has an active UnitAssignment (location_id set by
|
||||
// the snapshot helper). Benched = no active assignment.
|
||||
// Retired, out-for-calibration, and roster-unknown units (emitters
|
||||
// not in the roster) are excluded from totals.
|
||||
const counts = {
|
||||
seismograph: { total: 0, deployed: 0, benched: 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) =====
|
||||
let seismoCount = 0;
|
||||
let slmCount = 0;
|
||||
let modemCount = 0;
|
||||
Object.values(data.units || {}).forEach(unit => {
|
||||
if (unit.retired) return; // Don't count retired units
|
||||
const deviceType = unit.device_type || 'seismograph';
|
||||
if (deviceType === 'seismograph') {
|
||||
seismoCount++;
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
slmCount++;
|
||||
} else if (deviceType === 'modem') {
|
||||
modemCount++;
|
||||
Object.entries(data.units || {}).forEach(([uid, unit]) => {
|
||||
if (unit.retired || unit.out_for_calibration) return;
|
||||
if (unknownIds.has(uid)) return;
|
||||
const dt = unit.device_type || 'seismograph';
|
||||
const bucket = counts[dt];
|
||||
if (!bucket) return; // skip modems and anything else
|
||||
|
||||
bucket.total++;
|
||||
if (unit.location_id) {
|
||||
bucket.deployed++;
|
||||
} else {
|
||||
bucket.benched++;
|
||||
}
|
||||
|
||||
// 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 =====
|
||||
renderFilteredDashboard(data);
|
||||
|
||||
@@ -74,6 +74,22 @@
|
||||
</svg>
|
||||
Generate Combined Report
|
||||
</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 %}
|
||||
<button onclick="openMergeModal()"
|
||||
title="Merge this project into another (consolidates duplicates)"
|
||||
@@ -87,6 +103,338 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
{% if 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="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 }}');"
|
||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||
title="View live chart">
|
||||
@@ -20,41 +27,44 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a href="/slm/{{ unit.id }}" class="block">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||
{% if unit.slm_model %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if unit.address %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
||||
{% elif unit.location %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
||||
<a href="/slm/{{ unit.id }}" class="block pr-24">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||
{% if unit.slm_model %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% elif unit.measurement_state == "Start" %}
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% if unit.address %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
||||
{% elif unit.location %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if unit.slm_last_check %}
|
||||
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||
<!-- 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 %}
|
||||
<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 %}
|
||||
<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 in ["Start", "Measure"] %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
No recent check-in
|
||||
<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 %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if unit.cache_last_seen %}
|
||||
Last check: {{ unit.cache_last_seen|local_datetime }}
|
||||
{% elif unit.slm_last_check %}
|
||||
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||
{% else %}
|
||||
No recent check-in
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
</svg>
|
||||
Stop Live Stream
|
||||
</button>
|
||||
|
||||
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,17 +175,17 @@
|
||||
</div>
|
||||
|
||||
<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-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
||||
<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-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{% if current_status and current_status.ln1 %}{{ current_status.ln1 }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<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-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
||||
<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-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{% if current_status and current_status.ln2 %}{{ current_status.ln2 }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
@@ -432,6 +434,24 @@ function initializeChart() {
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
@@ -504,17 +554,24 @@ function initLiveDataStream(unitId) {
|
||||
window.chartData.timestamps = [];
|
||||
window.chartData.lp = [];
|
||||
window.chartData.leq = [];
|
||||
window.chartData.ln1 = [];
|
||||
window.chartData.ln2 = [];
|
||||
}
|
||||
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||
window.liveChart.data.labels = [];
|
||||
window.liveChart.data.datasets[0].data = [];
|
||||
window.liveChart.data.datasets[1].data = [];
|
||||
window.liveChart.data.datasets.forEach(ds => ds.data = []);
|
||||
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 wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`;
|
||||
|
||||
window.currentWebSocket = new WebSocket(wsUrl);
|
||||
|
||||
@@ -530,7 +587,11 @@ function initLiveDataStream(unitId) {
|
||||
window.currentWebSocket.onmessage = function(event) {
|
||||
try {
|
||||
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);
|
||||
updateLiveChart(data);
|
||||
} 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
|
||||
function updateLiveMetrics(data) {
|
||||
if (document.getElementById('live-lp')) {
|
||||
@@ -570,11 +646,20 @@ function updateLiveMetrics(data) {
|
||||
if (document.getElementById('live-lmax')) {
|
||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||
}
|
||||
if (document.getElementById('live-lmin')) {
|
||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||
// Only update Ln values when the frame actually carries them. DRD stream
|
||||
// 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')) {
|
||||
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
||||
if (data.ln1_label && document.getElementById('live-ln1-label')) {
|
||||
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 = {
|
||||
timestamps: [],
|
||||
lp: [],
|
||||
leq: []
|
||||
leq: [],
|
||||
ln1: [],
|
||||
ln2: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -593,12 +680,17 @@ function updateLiveChart(data) {
|
||||
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||
window.chartData.lp.push(parseFloat(data.lp || 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
|
||||
if (window.chartData.timestamps.length > 60) {
|
||||
// Keep a rolling window large enough to hold the ~2h backfill (one point/min)
|
||||
// plus a good run of live points before the oldest scroll off.
|
||||
if (window.chartData.timestamps.length > 600) {
|
||||
window.chartData.timestamps.shift();
|
||||
window.chartData.lp.shift();
|
||||
window.chartData.leq.shift();
|
||||
window.chartData.ln1.shift();
|
||||
window.chartData.ln2.shift();
|
||||
}
|
||||
|
||||
// Update chart if available
|
||||
@@ -606,6 +698,8 @@ function updateLiveChart(data) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
|
||||
if (typeof checkFTPStatus === 'function') {
|
||||
checkFTPStatus(unitId);
|
||||
}
|
||||
if (typeof htmx !== 'undefined') {
|
||||
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
||||
htmx.trigger('#slm-list', 'load');
|
||||
}
|
||||
}, 1500);
|
||||
@@ -604,8 +604,10 @@ async function toggleSLMDeployed() {
|
||||
successDiv.classList.remove('hidden');
|
||||
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
||||
|
||||
// Refresh any SLM list on the page
|
||||
if (typeof htmx !== 'undefined') {
|
||||
// Refresh any SLM list on the page (only if one is actually present —
|
||||
// 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');
|
||||
}
|
||||
} 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 %}
|
||||
<!-- 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">
|
||||
<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">
|
||||
@@ -17,6 +17,28 @@
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
||||
</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>
|
||||
|
||||
<!-- Header (loads dynamically) -->
|
||||
@@ -2074,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>
|
||||
{% endblock %}
|
||||
|
||||
+52
-3
@@ -130,8 +130,8 @@
|
||||
</label>
|
||||
<select id="mic-unit-pref"
|
||||
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="dBL" selected>dB(L) — sound pressure level</option>
|
||||
<option value="psi">psi — raw pressure</option>
|
||||
<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.
|
||||
@@ -472,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">
|
||||
Save Defaults
|
||||
</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>
|
||||
@@ -787,7 +801,7 @@ async function loadPreferences() {
|
||||
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
|
||||
|
||||
// Load event-report mic units
|
||||
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'dBL';
|
||||
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'psi';
|
||||
|
||||
// Load status thresholds
|
||||
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
||||
@@ -890,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 ==========
|
||||
|
||||
// Merge Mode Import
|
||||
|
||||
@@ -112,4 +112,267 @@
|
||||
</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 %}
|
||||
|
||||
@@ -51,13 +51,31 @@
|
||||
|
||||
<!-- 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 class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- Current Metrics -->
|
||||
@@ -81,14 +99,14 @@
|
||||
</div>
|
||||
|
||||
<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-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
||||
<p id="chart-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L1</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>
|
||||
</div>
|
||||
|
||||
<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-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
||||
<p id="chart-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L10</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,9 +168,18 @@ window.selectedUnitId = null;
|
||||
window.dashboardChartData = {
|
||||
timestamps: [],
|
||||
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
|
||||
function initializeDashboardChart() {
|
||||
if (typeof Chart === 'undefined') {
|
||||
@@ -194,6 +221,26 @@ function initializeDashboardChart() {
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
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();
|
||||
}
|
||||
|
||||
// Reset data
|
||||
window.dashboardChartData = {
|
||||
timestamps: [],
|
||||
lp: [],
|
||||
leq: []
|
||||
};
|
||||
// Reset data for the newly-selected unit (clears any prior unit's line)
|
||||
window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] };
|
||||
if (window.dashboardChart) {
|
||||
window.dashboardChart.data.labels = [];
|
||||
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
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -257,6 +316,7 @@ function showLiveChart(unitId) {
|
||||
|
||||
function closeLiveChart() {
|
||||
stopDashboardStream();
|
||||
stopPanelCachePolling();
|
||||
document.getElementById('live-chart-panel').classList.add('hidden');
|
||||
window.selectedUnitId = null;
|
||||
}
|
||||
@@ -270,17 +330,12 @@ function startDashboardStream() {
|
||||
window.dashboardWebSocket.close();
|
||||
}
|
||||
|
||||
// Reset chart data
|
||||
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
||||
if (window.dashboardChart) {
|
||||
window.dashboardChart.data.labels = [];
|
||||
window.dashboardChart.data.datasets[0].data = [];
|
||||
window.dashboardChart.data.datasets[1].data = [];
|
||||
window.dashboardChart.update();
|
||||
}
|
||||
// The live WS takes over from the cache poller; keep the backfilled trail on
|
||||
// the chart so the live frames continue the line instead of blanking it.
|
||||
stopPanelCachePolling();
|
||||
|
||||
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);
|
||||
|
||||
@@ -293,6 +348,10 @@ function startDashboardStream() {
|
||||
window.dashboardWebSocket.onmessage = function(event) {
|
||||
try {
|
||||
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);
|
||||
updateDashboardChart(data);
|
||||
} catch (error) {
|
||||
@@ -316,37 +375,219 @@ function stopDashboardStream() {
|
||||
window.dashboardWebSocket.close();
|
||||
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) {
|
||||
document.getElementById('chart-lp').textContent = data.lp || '--';
|
||||
document.getElementById('chart-leq').textContent = data.leq || '--';
|
||||
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
||||
document.getElementById('chart-lmin').textContent = data.lmin || '--';
|
||||
document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
|
||||
// Guard: DRD stream frames omit percentiles, so only overwrite when present
|
||||
// (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) {
|
||||
const cd = window.dashboardChartData;
|
||||
const now = new Date();
|
||||
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
||||
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
||||
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
||||
cd.timestamps.push(now.toLocaleTimeString());
|
||||
cd.lp.push(numOrNull(data.lp));
|
||||
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
|
||||
if (window.dashboardChartData.timestamps.length > 60) {
|
||||
window.dashboardChartData.timestamps.shift();
|
||||
window.dashboardChartData.lp.shift();
|
||||
window.dashboardChartData.leq.shift();
|
||||
// Keep a generous window (backfill seeds up to ~120 points from the 2h trail).
|
||||
if (cd.timestamps.length > 600) {
|
||||
cd.timestamps.shift();
|
||||
cd.lp.shift();
|
||||
cd.leq.shift();
|
||||
cd.ln1.shift();
|
||||
cd.ln2.shift();
|
||||
}
|
||||
|
||||
if (window.dashboardChart) {
|
||||
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
||||
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
||||
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
function openDeviceConfigModal(unitId) {
|
||||
// Call the unified modal function from slm_settings_modal.html
|
||||
|
||||
+47
-35
@@ -129,6 +129,15 @@
|
||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
|
||||
<p id="viewLocationContainer" class="mt-1">
|
||||
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
||||
<span id="viewLocationText">--</span>
|
||||
</a>
|
||||
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
@@ -639,18 +648,12 @@
|
||||
{% include "partials/project_picker.html" with context %}
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
|
||||
<!-- Coordinates -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
|
||||
<!-- Address / coordinates are managed on the project's
|
||||
MonitoringLocation, not the unit itself. Edit them on
|
||||
the project page. -->
|
||||
<div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Address & coordinates are set on the deployment location.
|
||||
Open the project to edit them.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -848,16 +851,6 @@
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
@@ -1168,8 +1161,28 @@ function populateViewMode() {
|
||||
if (projectLink) projectLink.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||
// Deployment Location — comes from the active UnitAssignment →
|
||||
// MonitoringLocation. Show project link if present, otherwise
|
||||
// "Not deployed" placeholder.
|
||||
const locLink = document.getElementById('viewLocationLink');
|
||||
const locText = document.getElementById('viewLocationText');
|
||||
const locNoLink = document.getElementById('viewLocationNoLink');
|
||||
const activeLoc = currentUnit.active_location;
|
||||
if (activeLoc && activeLoc.location_id) {
|
||||
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
|
||||
if (locLink) {
|
||||
locLink.href = `/projects/${activeLoc.project_id}`;
|
||||
locLink.classList.remove('hidden');
|
||||
}
|
||||
if (locNoLink) locNoLink.classList.add('hidden');
|
||||
} else {
|
||||
if (locLink) locLink.classList.add('hidden');
|
||||
if (locNoLink) locNoLink.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Address / coordinates also come from the active assignment.
|
||||
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
|
||||
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
|
||||
|
||||
// Seismograph fields
|
||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||
@@ -1327,8 +1340,6 @@ function populateEditForm() {
|
||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('address').value = currentUnit.address || '';
|
||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
||||
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||
@@ -1609,8 +1620,13 @@ function initUnitMap() {
|
||||
// Update marker (can be called multiple times)
|
||||
updateMapMarker(lat, lon);
|
||||
|
||||
// Update location text
|
||||
// Update location text — prefer the assignment's location name, fall
|
||||
// back to address, then coordinates.
|
||||
const locationParts = [];
|
||||
const loc = currentUnit.active_location;
|
||||
if (loc && loc.name) {
|
||||
locationParts.push(loc.name);
|
||||
}
|
||||
if (currentUnit.address) {
|
||||
locationParts.push(currentUnit.address);
|
||||
}
|
||||
@@ -1724,13 +1740,12 @@ async function uploadPhoto(file) {
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show success message with metadata info
|
||||
// Show success message with metadata info. Location is on the
|
||||
// assignment's MonitoringLocation now, so we just surface what GPS
|
||||
// came in — the backend no longer mutates the unit row.
|
||||
let message = 'Photo uploaded successfully!';
|
||||
if (result.metadata && result.metadata.coordinates) {
|
||||
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||
if (result.coordinates_updated) {
|
||||
message += ' (Unit coordinates updated automatically)';
|
||||
}
|
||||
} else {
|
||||
message += ' No GPS data found in photo.';
|
||||
}
|
||||
@@ -1738,11 +1753,8 @@ async function uploadPhoto(file) {
|
||||
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||
statusDiv.textContent = message;
|
||||
|
||||
// Reload photos and unit data
|
||||
// Reload photos
|
||||
await loadPhotos();
|
||||
if (result.coordinates_updated) {
|
||||
await loadUnitData();
|
||||
}
|
||||
|
||||
// Hide status after 5 seconds
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user