Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 766f64f35f | |||
| da128f6173 | |||
| 20f62a5c0a | |||
| 01180d5725 | |||
| f0a13ea2ff | |||
| 0394f4b0c8 | |||
| eb91441904 | |||
| 25a4a28433 | |||
| b8e4718318 | |||
| c3eb900b7e | |||
| c74dada8b3 | |||
| d75f405857 | |||
| 446d8704f9 | |||
| c04830a0ad | |||
| b11e1a554f | |||
| ad6de946b5 | |||
| d44625374d | |||
| 33069a070d | |||
| ec5d986ac5 | |||
| 0888da32b4 | |||
| 485e3f165b | |||
| 5f02a0bc21 | |||
| 684a487203 | |||
| 04cd6b9f24 | |||
| fe7cf91488 | |||
| c1bc391ba2 | |||
| a81764d4bc | |||
| a555cb74dd | |||
| 505c2e3ca5 | |||
| f760e81309 | |||
| 4839d14a22 | |||
| fa7dc39e5e | |||
| 0914cf0a75 | |||
| 29b974a1f7 | |||
| bececafe78 | |||
| 2da9493cb5 | |||
| b908f394ed | |||
| 5455d3a931 | |||
| b971d19068 | |||
| 0103917870 | |||
| 3fc20e104a | |||
| 2031681d0f | |||
| 1cf80ea7ea | |||
| 26b4b1e7e4 | |||
| d3e221b6b1 | |||
| 9f40210057 | |||
| 6c048a9c30 | |||
| 80a8470b55 | |||
| a64c9ced65 | |||
| 182e224f3c | |||
| 2e832708f3 | |||
| 5e3645e229 | |||
| 88f258d1c7 | |||
| 711ef41e5f | |||
| e27aef33ac | |||
| 170dedb138 | |||
| d92d01dc56 | |||
| 17a1a83bdf | |||
| f5e93d5612 | |||
| bdc91177e2 | |||
| 3b818dcd97 | |||
| 61b144efd2 | |||
| c56b7f6c99 | |||
| 08fec696f1 | |||
| 7f561c2c9d | |||
| 38f2c751b8 | |||
| 78d72431b3 | |||
| 6c41ccf1bd | |||
| 56bd3041cf | |||
| 623ef648b7 | |||
| 5ed00bf70e | |||
| 43c804d0c4 | |||
| c1f995b4d3 |
+233
@@ -5,6 +5,239 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
### Portal authentication (Phase 1)
|
||||
- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link).
|
||||
- Per-project session isolation (a session for one project can't read another's data); brute-force lockout (5 tries / 15 min) on the password gate.
|
||||
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links and the `portal_admin.py mint-link` command.
|
||||
- **Upgrade:** run `python3 backend/migrate_add_project_portal_auth.py` per DB. Set `COOKIE_SECURE=true` once served over HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## [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,26 @@
|
||||
"""Password hashing for the client portal — argon2id via argon2-cffi.
|
||||
|
||||
Kept separate from portal_auth (cookie signing) so the future operator auth can
|
||||
reuse the same hasher. Never store or log raw passwords."""
|
||||
import secrets
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(raw: str) -> str:
|
||||
"""Return an argon2id hash string for a raw password."""
|
||||
return _ph.hash(raw)
|
||||
|
||||
|
||||
def verify_password(raw: str, hashed: str) -> bool:
|
||||
"""True iff raw matches the stored hash. Never raises."""
|
||||
try:
|
||||
return _ph.verify(hashed, raw)
|
||||
except Exception: # argon2 raises on mismatch/garbage; treat all as "no match"
|
||||
return False
|
||||
|
||||
|
||||
def generate_password(n_bytes: int = 12) -> str:
|
||||
"""A strong, URL-safe shareable password (~16 chars for n_bytes=12)."""
|
||||
return secrets.token_urlsafe(n_bytes)
|
||||
+107
-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
|
||||
|
||||
@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,14 @@ 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)
|
||||
|
||||
# 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 +183,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 +198,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 +409,82 @@ 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,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/portal-preview")
|
||||
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
|
||||
"""Operator testing shortcut: open this project's client portal (no CLI)."""
|
||||
from backend.models import Project
|
||||
from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE
|
||||
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 = mint_portal_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", secure=COOKIE_SECURE)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/portal-access")
|
||||
async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Current portal-access state for the operator panel."""
|
||||
from backend.models import Project
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||
link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \
|
||||
if (p.portal_enabled and p.portal_link_token) else None
|
||||
return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash),
|
||||
"link_url": link_url}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/portal-access/enable")
|
||||
async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Turn the portal on; mint a link token if one doesn't exist yet."""
|
||||
import secrets
|
||||
from backend.models import Project
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||
if not p.portal_link_token:
|
||||
p.portal_link_token = secrets.token_urlsafe(24)
|
||||
p.portal_enabled = True
|
||||
db.commit()
|
||||
link_url = str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}"
|
||||
return {"enabled": True, "has_password": bool(p.portal_password_hash), "link_url": link_url}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/portal-access/password")
|
||||
async def project_portal_access_password(project_id: str, db: Session = Depends(get_db)):
|
||||
"""Generate a fresh strong password, store its hash, return the raw once."""
|
||||
from backend.models import Project
|
||||
from backend.auth_passwords import hash_password, generate_password
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||
raw = generate_password()
|
||||
p.portal_password_hash = hash_password(raw)
|
||||
db.commit()
|
||||
return {"password": raw}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/portal-access/disable")
|
||||
async def project_portal_access_disable(project_id: str, db: Session = Depends(get_db)):
|
||||
"""Turn the portal off and rotate the link token (kills the old link)."""
|
||||
import secrets
|
||||
from backend.models import Project
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||
p.portal_enabled = False
|
||||
p.portal_link_token = secrets.token_urlsafe(24) # rotate so the old link 404s
|
||||
db.commit()
|
||||
return {"enabled": False}
|
||||
|
||||
|
||||
@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 'dBL'"
|
||||
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
|
||||
)
|
||||
# Backfill the single row that should exist (id=1) to the default,
|
||||
# in case the column ends up NULL on existing rows.
|
||||
# Backfill any rows where the column ended up NULL.
|
||||
cur.execute(
|
||||
"UPDATE user_preferences SET mic_unit_pref = 'dBL' "
|
||||
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||
"WHERE mic_unit_pref IS NULL"
|
||||
)
|
||||
print("Added mic_unit_pref column (default 'psi').")
|
||||
else:
|
||||
print("mic_unit_pref column already exists.")
|
||||
|
||||
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
|
||||
# 'dBL' default get bumped to 'psi'. If any operator deliberately
|
||||
# chose 'dBL' through Settings before this migration runs they'd
|
||||
# get reset — acceptable trade-off given the small user base and
|
||||
# the fact the setting is one click to restore.
|
||||
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||
"WHERE mic_unit_pref = 'dBL'")
|
||||
flipped = cur.rowcount
|
||||
if flipped:
|
||||
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Added mic_unit_pref to user_preferences (default 'dBL').")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration: Project portal auth (Phase 1).
|
||||
|
||||
Adds the per-project portal gate columns to `projects`:
|
||||
- portal_enabled (BOOLEAN, default 0)
|
||||
- portal_password_hash (TEXT, nullable)
|
||||
- portal_link_token (TEXT, nullable) [+ unique index]
|
||||
|
||||
Idempotent. Run once per existing DB:
|
||||
docker exec terra-view-terra-view-1 python3 backend/migrate_add_project_portal_auth.py
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
_COLUMNS = {
|
||||
"portal_enabled": "BOOLEAN DEFAULT 0",
|
||||
"portal_password_hash": "TEXT",
|
||||
"portal_link_token": "TEXT",
|
||||
}
|
||||
|
||||
|
||||
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 these columns 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()}
|
||||
|
||||
for col, ddl in _COLUMNS.items():
|
||||
if col in existing:
|
||||
print(f"○ Column already exists: projects.{col}")
|
||||
continue
|
||||
try:
|
||||
cursor.execute(f"ALTER TABLE projects ADD COLUMN {col} {ddl}")
|
||||
print(f"✓ Added column: projects.{col} ({ddl})")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"✗ Failed to add projects.{col}: {e}")
|
||||
|
||||
# Unique index on the link token (separate from ADD COLUMN; idempotent via IF NOT EXISTS).
|
||||
try:
|
||||
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_projects_portal_link_token "
|
||||
"ON projects (portal_link_token)")
|
||||
print("✓ Ensured unique index: ix_projects_portal_link_token")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"✗ Failed to create index: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("\n✓ Project portal-auth migration complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
+43
-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,11 @@ 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)
|
||||
# --- Client portal (Phase 1: per-project link + password gate) ---
|
||||
portal_enabled = Column(Boolean, default=False) # is the portal open for this project
|
||||
portal_password_hash = Column(String, nullable=True) # argon2 hash of the shared password
|
||||
portal_link_token = Column(String, nullable=True, unique=True, index=True) # unguessable token in the secure link
|
||||
site_address = Column(String, nullable=True)
|
||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||
start_date = Column(Date, nullable=True)
|
||||
@@ -702,3 +709,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,161 @@
|
||||
#!/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-link is RETIRED — per-client magic URLs (/portal/enter) no longer exist.
|
||||
# Client access is now per-PROJECT + password: open the project's page in
|
||||
# Terra-View → "Portal access" to enable it, generate a password, and copy
|
||||
# the /portal/p/<token> link. (create-client / link-project / list / revoke
|
||||
# still operate on the underlying Client/token rows.)
|
||||
|
||||
# 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>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
# Retired: the per-client magic URL (/portal/enter/...) was removed when the
|
||||
# portal moved to per-project + password access. Minting a token here would
|
||||
# only produce a dead link.
|
||||
sys.exit(
|
||||
"mint-link is retired: per-client magic URLs (/portal/enter/...) no longer exist.\n"
|
||||
"Client access is now per-project + password. In Terra-View, open the project's page →\n"
|
||||
"'Portal access' to enable the portal, generate a password, and copy the /portal/p/<token>\n"
|
||||
"link to send the client."
|
||||
)
|
||||
|
||||
|
||||
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,193 @@
|
||||
"""
|
||||
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 fastapi import Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import Client, ClientAccessToken, Project
|
||||
|
||||
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
|
||||
# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
|
||||
# the Synology reverse proxy). Default false so plain-HTTP dev still works.
|
||||
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- Phase-1 per-project password gate -------------------------------------------
|
||||
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
|
||||
# owning exactly that project. The project is linked to it via project.client_id so
|
||||
# the existing client-scoped routes (which resolve projects by Project.client_id ==
|
||||
# client.id) surface exactly this one project for the portal session — per-project
|
||||
# isolation with no route changes. (Phase 1 repurposes project.client_id for this; a
|
||||
# real per-client model is the deferred multi-tenant work.)
|
||||
|
||||
|
||||
def portal_client_for_project(project, db) -> Client:
|
||||
"""Get-or-create the dedicated 1:1 portal client for a project, and link the
|
||||
project to it so the client-scoped routes resolve exactly this project."""
|
||||
slug = f"portal-{project.id}"
|
||||
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 "Client"),
|
||||
slug=slug, active=True)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
if project.client_id != client.id:
|
||||
project.client_id = client.id # without this, the client owns no projects
|
||||
db.flush()
|
||||
return client
|
||||
|
||||
|
||||
def mint_portal_session(project, db) -> str:
|
||||
"""Ensure the project's portal client + an access token exist; return the token
|
||||
id to seal into a session cookie. Reuses an existing token to avoid clutter."""
|
||||
client = portal_client_for_project(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="portal")
|
||||
db.add(tok)
|
||||
db.commit()
|
||||
return tok.id
|
||||
|
||||
|
||||
def resolve_project_by_link_token(link_token: str, db):
|
||||
"""Return the portal-enabled Project for a link token, or None."""
|
||||
if not link_token:
|
||||
return None
|
||||
return db.query(Project).filter_by(
|
||||
portal_link_token=link_token, portal_enabled=True).first()
|
||||
|
||||
|
||||
# In-memory brute-force lockout, keyed per link_token (the password is shared per
|
||||
# project, so per-IP granularity buys nothing and an IP term only lets an attacker
|
||||
# reset the budget by rotating source IPs). Resets on restart; adequate for a
|
||||
# read-only surface behind the UniFi edge. Single-worker dev; multi-worker would
|
||||
# need a shared store.
|
||||
MAX_ATTEMPTS = 5
|
||||
LOCK_SECONDS = 15 * 60
|
||||
_failures: dict = {} # key -> (count, first_failure_epoch)
|
||||
|
||||
|
||||
def is_locked(key: str) -> bool:
|
||||
rec = _failures.get(key)
|
||||
if not rec:
|
||||
return False
|
||||
count, first = rec
|
||||
if count < MAX_ATTEMPTS:
|
||||
return False
|
||||
if (time.time() - first) > LOCK_SECONDS:
|
||||
_failures.pop(key, None) # window expired
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def register_failure(key: str) -> None:
|
||||
count, first = _failures.get(key, (0, time.time()))
|
||||
_failures[key] = (count + 1, first)
|
||||
|
||||
|
||||
def clear_failures(key: str) -> None:
|
||||
_failures.pop(key, None)
|
||||
@@ -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,375 @@
|
||||
"""
|
||||
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
||||
|
||||
A client opens a per-project secure link (/portal/p/{link_token}), enters the
|
||||
shared password, and gets a signed session cookie scoped to that project; they
|
||||
then see that project's 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, Form
|
||||
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,
|
||||
COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE,
|
||||
resolve_project_by_link_token, mint_portal_session,
|
||||
is_locked, register_failure, clear_failures,
|
||||
)
|
||||
from backend.auth_passwords import verify_password
|
||||
|
||||
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("/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("/p/{link_token}")
|
||||
def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Secure per-project link: resolve the project from the token, prompt for the
|
||||
shared password. Generic page if the token is unknown/disabled (no leak)."""
|
||||
project = resolve_project_by_link_token(link_token, db)
|
||||
if not project or not project.portal_password_hash:
|
||||
# unknown token, disabled portal, or enabled-but-no-password-set — all look
|
||||
# identical to a client (no existence/config leak, no self-lockout on a
|
||||
# passwordless project).
|
||||
return templates.TemplateResponse(
|
||||
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
||||
status_code=404)
|
||||
return templates.TemplateResponse("portal/password.html", {
|
||||
"request": request, "link_token": link_token,
|
||||
"project_name": project.name, "error": None})
|
||||
|
||||
|
||||
@router.post("/p/{link_token}")
|
||||
def portal_password_submit(link_token: str, request: Request,
|
||||
password: str = Form(...), db: Session = Depends(get_db)):
|
||||
"""Verify the shared password; on success mint a project-scoped session cookie."""
|
||||
project = resolve_project_by_link_token(link_token, db)
|
||||
if not project or not project.portal_password_hash:
|
||||
# unknown token, disabled portal, or enabled-but-no-password-set — all look
|
||||
# identical to a client (no existence/config leak, no self-lockout on a
|
||||
# passwordless project).
|
||||
return templates.TemplateResponse(
|
||||
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
||||
status_code=404)
|
||||
|
||||
# Shared per-project password → lock per token. (Keying on IP too only enabled a
|
||||
# bypass via source-IP rotation, and behind the reverse proxy every client shares
|
||||
# one IP anyway.)
|
||||
lock_key = link_token
|
||||
if is_locked(lock_key):
|
||||
return templates.TemplateResponse("portal/password.html", {
|
||||
"request": request, "link_token": link_token, "project_name": project.name,
|
||||
"error": "Too many attempts. Try again in 15 minutes."}, status_code=200)
|
||||
|
||||
if not verify_password(password, project.portal_password_hash):
|
||||
register_failure(lock_key)
|
||||
return templates.TemplateResponse("portal/password.html", {
|
||||
"request": request, "link_token": link_token, "project_name": project.name,
|
||||
"error": "Incorrect password."}, status_code=200)
|
||||
|
||||
clear_failures(lock_key)
|
||||
token_id = mint_portal_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", secure=COOKIE_SECURE)
|
||||
logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
|
||||
return resp
|
||||
|
||||
|
||||
@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),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
# 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:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
|
||||
if response.status_code == 200:
|
||||
return response.json().get("measurement_state")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
|
||||
if deployed_units:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
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
|
||||
@@ -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,214 @@
|
||||
# Client Portal — Design & Build Plan
|
||||
|
||||
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
|
||||
|
||||
> **Update (Phase-1 auth landed):** the interim magic-link gate described below is
|
||||
> **retired** — client access is now a per-project secure link + shared password
|
||||
> (argon2). See the design at `docs/superpowers/specs/2026-06-15-portal-auth-design.md`
|
||||
> and the build plan at `docs/superpowers/plans/2026-06-15-portal-auth.md`. The
|
||||
> operator manages access from each project's **Portal access** panel.
|
||||
|
||||
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`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
# Portal Authentication — Design & Build Plan
|
||||
|
||||
**Status:** in development (`feat/portal-auth`) · **Targets:** 0.14.x · **Date:** 2026-06-15
|
||||
|
||||
Supersedes the interim shareable magic-link described in
|
||||
[CLIENT_PORTAL.md](../../CLIENT_PORTAL.md) with a real password gate.
|
||||
|
||||
## Goal
|
||||
|
||||
Give a client a **secure link + password** that opens a **read-only dashboard** —
|
||||
live data plus access to historical data — for the machines commissioned on
|
||||
**their project**. Nothing else: no device control, no editing, no internal pages.
|
||||
|
||||
This is the first real, internet-facing, client-credentialed surface in the
|
||||
system.
|
||||
|
||||
## Scope
|
||||
|
||||
**Phase 1 (this spec — build now):** per-project, password-gated, read-only portal.
|
||||
|
||||
**Deferred (designed, not built — captured below so nothing is lost):**
|
||||
- **Operator auth** — logins + roles for the *internal* app (you / parents).
|
||||
Full design in [Deferred A](#deferred-a--operator-auth-designed-not-built).
|
||||
- **Full multi-tenancy** — per-client rollups, per-project separation within a
|
||||
client, individual client user accounts, and extending the portal to all
|
||||
client-relevant data. [Deferred B](#deferred-b--full-multi-tenancy).
|
||||
|
||||
## Principles (the portal's standing charter)
|
||||
|
||||
1. **Read-only.** A client can look, never touch.
|
||||
2. **Strictly scoped, server-side.** Never trust a project / location / unit id
|
||||
from the request — always re-resolve ownership.
|
||||
3. **Cache-first.** Portal live data comes from SLMM's cache (the same cached
|
||||
reads the internal dashboard uses). A client can never make us hit the device.
|
||||
4. **The gate is a swappable seam.** Everything routes through the scoping layer
|
||||
the portal already has; auth is the thin thing in front of it.
|
||||
|
||||
## The model
|
||||
|
||||
- **Tenant unit = the project.** Each project is its own portal: one link, one
|
||||
password, showing that project's commissioned machines.
|
||||
- **Shared credential — "company / project-manager wide."** No individual client
|
||||
accounts. Because access is read-only, one shared password per project is an
|
||||
acceptable trade. (Per-person accounts are a Deferred-B item.)
|
||||
- **The link identifies the project; the password authorizes.** A password alone
|
||||
can't say *which* project — so the link carries an unguessable, revocable
|
||||
per-project token, and the password is the shared secret gating it.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two layers, two subdomains (hosting target: office Synology NAS behind a UniFi
|
||||
UXG Max; own domain `terra-mechanics.com`).
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
UniFi UXG Max ── Layer 1 (IT pro): firewall, IPS/IDS, GeoIP allow-list,
|
||||
│ kill-switch rule, 443 only
|
||||
Synology NAS ── DSM reverse proxy + Let's Encrypt wildcard TLS
|
||||
│
|
||||
├─ terra-view.terra-mechanics.com → internal app (operator auth = Deferred A)
|
||||
└─ portal.terra-mechanics.com → LOCKED to /portal/* only, password gate
|
||||
```
|
||||
|
||||
The portal subdomain is **restricted to `/portal/*` at the reverse proxy** — a
|
||||
client on `portal.` physically cannot reach `/roster`, `/admin/*`, etc., even by
|
||||
guessing URLs. This path-lock is a load-bearing control for as long as the
|
||||
internal app remains unauthenticated (until Deferred A lands).
|
||||
|
||||
## Data model
|
||||
|
||||
Add three columns to **`Project`**:
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `portal_enabled` | bool, default `false` | Is the portal open for this project. |
|
||||
| `portal_password_hash` | text, nullable | argon2id hash of the shared password. Never plaintext. |
|
||||
| `portal_link_token` | text, unique, nullable | Unguessable token in the secure link; identifies the project without exposing its raw id, and is revocable (regenerate → old link dies). |
|
||||
|
||||
**Reused unchanged:** the `Client → Project → MonitoringLocation →
|
||||
UnitAssignment → unit` scoping chain and the existing read-only scoped data
|
||||
routes (`resolve_client_location` + live / history / events).
|
||||
|
||||
**Migration:** `migrate_add_project_portal_auth.py` — an `ALTER TABLE` adding the
|
||||
three columns to the existing (non-empty) `projects` table. Same pattern as
|
||||
`migrate_add_client_portal.py`; `create_all` won't add columns to an existing
|
||||
table.
|
||||
|
||||
## Auth flow
|
||||
|
||||
1. **Operator enables + shares.** On the project page, the operator turns the
|
||||
portal on; the system generates a strong password + a `portal_link_token`; the
|
||||
operator copies **link + password** to send the client.
|
||||
2. **Client opens the link** `portal.terra-mechanics.com/portal/p/{link_token}` →
|
||||
the project is resolved from the token → a **password prompt** renders.
|
||||
3. **Client submits the password** → argon2-verified against
|
||||
`portal_password_hash`. On success, a **signed session cookie scoped to that
|
||||
project** is set (HMAC via the existing `SECRET_KEY` cookie machinery), and
|
||||
they are redirected to the project dashboard.
|
||||
4. **Subsequent requests** re-validate the cookie (signature + project still
|
||||
`portal_enabled` + within cookie max-age) and serve the existing read-only
|
||||
scoped data.
|
||||
5. **Logout** clears the cookie. **Revoke** = disable the portal or regenerate the
|
||||
token / password, which kills outstanding links and any session minted from
|
||||
them on the next request.
|
||||
|
||||
**Lockout:** track failed attempts (per token + IP); after 5 failures refuse for
|
||||
a 15-minute cooldown. Combined with the UniFi GeoIP/IPS edge, that's solid for a
|
||||
read-only surface.
|
||||
|
||||
**Shared cookie machinery:** lift the portal's cookie sign/verify out of
|
||||
`portal_auth.py` into a small shared `backend/auth_cookies.py` — one signer, so
|
||||
the future operator auth (Deferred A) reuses it instead of copy-pasting crypto.
|
||||
|
||||
### Relationship to the existing portal code
|
||||
|
||||
The portal today is *client-scoped* (a `ClientAccessToken` magic-link → a cookie
|
||||
covering all of a client's projects, with a `/portal` overview). Phase 1 makes the
|
||||
entry point *project-scoped*:
|
||||
|
||||
- The **`/portal/p/{link_token}` + password** flow becomes the way in; the
|
||||
interim client magic-link (`/portal/enter/{token}`, `/portal/open/*`,
|
||||
`PORTAL_OPEN_LINKS`) is **retired** in its favor.
|
||||
- The existing read-only views (`/portal/location/{id}`, live / history / events)
|
||||
and the scoping helper are **reused as-is**, just resolved against the project in
|
||||
the session cookie instead of the client.
|
||||
- `Client` / `ClientAccessToken` rows are **left in place** (no destructive
|
||||
migration) — they become the substrate for the Deferred-B per-client rollup.
|
||||
|
||||
## Operator "Portal access" panel
|
||||
|
||||
On the project detail page (internal app), a panel that:
|
||||
- Toggles `portal_enabled`.
|
||||
- **Regenerate password** → shows a freshly generated strong password **once** for
|
||||
the operator to copy.
|
||||
- **Copy link** → the `/portal/p/{token}` URL.
|
||||
- **Revoke** → regenerate the token (old link dies) and/or disable the portal.
|
||||
|
||||
This is an operator action. Until operator auth lands (Deferred A), it sits behind
|
||||
the same posture as the rest of the internal app — see Security notes.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Bad password** → generic "incorrect password" + increment fail count.
|
||||
- **Unknown / disabled / revoked token** → generic "this portal link is no longer
|
||||
active" page (no project-existence leak).
|
||||
- **Locked out** → "too many attempts, try again in 15 minutes."
|
||||
- **Expired / invalid cookie** → back to the password prompt.
|
||||
- **Portal disabled after a session started** → next request bounced to the prompt.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Implement on `feat/portal-auth` → review → merge to `dev`.
|
||||
2. **Migration** `migrate_add_project_portal_auth.py` on each DB (dev + prod), same
|
||||
drill as the client-portal migration.
|
||||
3. **`SECRET_KEY`** must be a real value in prod (already required for the existing
|
||||
portal cookie; the password gate reuses it).
|
||||
4. **Hosting:** DSM reverse proxy routes `portal.` → app, locked to `/portal/*`;
|
||||
Let's Encrypt wildcard TLS; cookies `Secure` once on TLS. UXG Max GeoIP + IPS +
|
||||
kill-switch handled by the IT pro.
|
||||
5. Enable a real project's portal, set a password, and test the full
|
||||
link → password → dashboard flow over HTTPS before sending a client.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** argon2 hash/verify; token resolution (valid / unknown / disabled);
|
||||
lockout counter; cookie sign/verify + scope check; "disabled mid-session" bounce.
|
||||
- **Scoping:** a session for project A cannot read project B's locations / history
|
||||
/ events (404, no existence leak).
|
||||
- **Manual smoke:** enable → copy link + password → open in a fresh browser →
|
||||
wrong password (lockout) → right password → see live + history → logout.
|
||||
|
||||
---
|
||||
|
||||
## Deferred A — Operator auth (designed, not built)
|
||||
|
||||
Logins + roles for the **internal** app (`terra-view.` subdomain). Closes the
|
||||
"internal app is wide open" hole. Full design, ready to lift into its own spec:
|
||||
|
||||
- **Two layers:** UniFi UXG Max edge (IT-pro owned — firewall, IPS, GeoIP,
|
||||
kill-switch, 443-only) + in-app auth (built by us). Internet-exposed with login
|
||||
(no VPN — deliberately, to spare non-technical family members).
|
||||
- **`OperatorUser` model:** `id, email (unique, lowercased), display_name,
|
||||
password_hash (argon2id), role, active, created_at, last_login_at,
|
||||
sessions_valid_from, failed_login_count, locked_until` (+ later `totp_secret`,
|
||||
`totp_enabled`).
|
||||
- **Role ladder:** `superadmin > admin > operator`.
|
||||
- `superadmin` = you — everything + account management (create/disable users,
|
||||
reset passwords, assign roles).
|
||||
- `admin` = your parents (company owners) + you — full run of the app, no
|
||||
operational restrictions.
|
||||
- `operator` = **future** restricted tier for hires; the ladder accepts it with
|
||||
no route changes.
|
||||
- The only thing gated above plain `admin` in v1 is account management
|
||||
(`superadmin`).
|
||||
- **Sessions:** stateless signed cookie reusing `auth_cookies.py` + `SECRET_KEY`
|
||||
(distinct cookie name from the portal). `sessions_valid_from` gives "log out
|
||||
everywhere" / revoke-on-password-change with no session table.
|
||||
- **Authorization:** one **deny-by-default middleware** gates the whole internal
|
||||
app (exempt: `/login`, `/logout`, `/health`, `/static/*`, `/portal/*`);
|
||||
`require_role("admin"|"superadmin")` guards specific routes. New routes are
|
||||
protected automatically.
|
||||
- **Lockout:** 5 fails → 15-min cooldown (doubling).
|
||||
- **2FA:** deferred; TOTP later, admin/superadmin account first.
|
||||
- **Safe rollout (no self-lockout):** ship behind a feature flag
|
||||
`OPERATOR_AUTH_ENABLED` (default **off** = app behaves as today) → seed the first
|
||||
`superadmin` via a small CLI (`backend/operator_admin.py`, modeled on
|
||||
`portal_admin.py`) → log in while still open → flip the flag on → create
|
||||
parents' accounts. Flag back off = instant escape hatch; break-glass =
|
||||
re-run seed / `reset-password` CLI in the container.
|
||||
- **`OperatorUser` is a brand-new table** → `create_all` builds it on startup; only
|
||||
the seed step is required.
|
||||
|
||||
## Deferred B — Full multi-tenancy
|
||||
|
||||
- Per-client **rollup**: one login spanning all of a client's projects.
|
||||
- Per-project **separation within a client** (true tenant isolation).
|
||||
- **Individual client user accounts** (per-person, optional roles) replacing the
|
||||
shared per-project password.
|
||||
- Extend the portal to **all client-relevant data types** (beyond sound:
|
||||
vibration, reports, etc.) — the long-term goal of "everything we can show a
|
||||
client."
|
||||
- All additive on the existing scoping seam — no teardown.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Auth-gated from day one (even the shared password) — never wide-open like the
|
||||
internal app currently is.
|
||||
- Scoping enforced server-side; client-supplied ids always re-checked.
|
||||
- Passwords argon2-hashed; link tokens unguessable + revocable; raw password shown
|
||||
once.
|
||||
- `SECRET_KEY` a real secret in prod; cookies `HttpOnly` + `SameSite=Lax` +
|
||||
`Secure` (once on TLS).
|
||||
- **Known risk:** the operator "Portal access" panel — and the whole internal app —
|
||||
is unauthenticated until Deferred A. Mitigated for now by the `/portal/*`
|
||||
path-lock on the public subdomain plus keeping the internal app off the public
|
||||
internet. Tracked in the hardening backlog (CLIENT_PORTAL.md).
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.3
|
||||
@@ -9,3 +9,5 @@ Pillow==10.1.0
|
||||
httpx==0.25.2
|
||||
openpyxl==3.1.2
|
||||
rapidfuzz==3.10.1
|
||||
schedule==1.2.2
|
||||
argon2-cffi==23.1.0
|
||||
|
||||
@@ -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="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
|
||||
</div>
|
||||
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
||||
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
||||
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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);
|
||||
|
||||
@@ -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,9 +27,8 @@
|
||||
</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">
|
||||
<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 %}
|
||||
@@ -36,25 +42,29 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Status badge + last-check on one line (moved off the top-right so it
|
||||
no longer collides with the refresh/chart/gear action icons). -->
|
||||
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||||
{% if unit.retired %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||
{% elif not unit.deployed %}
|
||||
<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>
|
||||
<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="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
||||
{% else %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if unit.slm_last_check %}
|
||||
<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 %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "portal/base.html" %}
|
||||
{% block title %}{{ project_name }}{% 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>
|
||||
<h1 class="text-2xl font-bold tracking-tight mb-1">{{ project_name }}</h1>
|
||||
<p class="text-[var(--text-dim)] text-sm mb-6">Enter the password to view this monitoring portal.</p>
|
||||
{% if error %}
|
||||
<p class="text-[var(--lvl-bad)] text-sm mb-4">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/portal/p/{{ link_token }}" class="panel p-5 text-left">
|
||||
<label class="block text-xs text-[var(--text-dim)] mb-1" for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autofocus required
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--panel-b)] text-[var(--text)] mb-4">
|
||||
<button type="submit"
|
||||
class="w-full px-4 py-2 rounded-lg bg-seismo-orange text-white font-medium hover:opacity-90">
|
||||
View portal
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% 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 access for this project -->
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button type="button" onclick="openPortalAccess()"
|
||||
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="Manage this project's client portal access">
|
||||
<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="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>
|
||||
Portal access
|
||||
</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>
|
||||
Preview
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header (loads dynamically) -->
|
||||
@@ -2074,5 +2096,97 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Portal access modal -->
|
||||
<div id="portal-access-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick="if(event.target===this)closePortalAccess()">
|
||||
<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 access</h3>
|
||||
<button onclick="closePortalAccess()" 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">
|
||||
Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
|
||||
<button id="pa-toggle" onclick="togglePortalEnabled()"
|
||||
class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600">…</button>
|
||||
</div>
|
||||
|
||||
<div id="pa-details" class="hidden space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="pa-link" 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="copyField('pa-link', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Password</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="pa-pass" readonly placeholder="•••••••• (set one below)"
|
||||
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="copyField('pa-pass', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||
</div>
|
||||
<button onclick="regeneratePassword()" class="mt-2 text-sm text-seismo-orange hover:text-seismo-navy font-medium">↻ Generate new password</button>
|
||||
<p class="text-xs text-gray-400 mt-1">Shown once — copy it now. Regenerating invalidates the old one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PA_PROJECT_ID = "{{ project_id }}";
|
||||
let paEnabled = false;
|
||||
function paToast(msg) { if (window.showToast) showToast(msg, 'error'); else alert(msg); }
|
||||
function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
|
||||
function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
|
||||
|
||||
function copyField(id, btn) {
|
||||
const inp = document.getElementById(id); 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 loadPortalAccess() {
|
||||
try {
|
||||
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access`);
|
||||
if (!r.ok) throw new Error('load failed');
|
||||
renderPortalAccess(await r.json());
|
||||
} catch (e) { paToast('Could not load portal access.'); }
|
||||
}
|
||||
function renderPortalAccess(j) {
|
||||
paEnabled = !!j.enabled;
|
||||
const toggle = document.getElementById('pa-toggle');
|
||||
const details = document.getElementById('pa-details');
|
||||
toggle.textContent = paEnabled ? 'On — click to disable' : 'Off — click to enable';
|
||||
toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
|
||||
(paEnabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
|
||||
details.classList.toggle('hidden', !paEnabled);
|
||||
document.getElementById('pa-link').value = (paEnabled && j.link_url) ? j.link_url : '';
|
||||
}
|
||||
async function togglePortalEnabled() {
|
||||
const action = paEnabled ? 'disable' : 'enable';
|
||||
try {
|
||||
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${action}`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error('toggle failed');
|
||||
const j = await r.json();
|
||||
renderPortalAccess(action === 'disable' ? { enabled: false, link_url: null } : j);
|
||||
} catch (e) { paToast(`Could not ${action} the portal.`); }
|
||||
}
|
||||
async function regeneratePassword() {
|
||||
try {
|
||||
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error('password failed');
|
||||
const j = await r.json();
|
||||
if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
|
||||
} catch (e) { paToast('Could not generate a password.'); }
|
||||
}
|
||||
</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,14 +51,32 @@
|
||||
|
||||
<!-- 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>
|
||||
<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 -->
|
||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Test harness: a throwaway SQLite DB per test, get_db overridden, a TestClient
|
||||
that does NOT run lifespan startup (so schedulers/SLMM polling stay off)."""
|
||||
import uuid
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from backend.database import Base, get_db
|
||||
import backend.models as models # noqa: F401 (ensure all tables are registered on Base)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session(tmp_path):
|
||||
db_file = tmp_path / "test.db"
|
||||
engine = create_engine(f"sqlite:///{db_file}", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
sess = TestingSession()
|
||||
try:
|
||||
yield sess
|
||||
finally:
|
||||
sess.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(db_session):
|
||||
from backend.main import app # imported lazily so module side effects are contained
|
||||
def _override():
|
||||
yield db_session
|
||||
app.dependency_overrides[get_db] = _override
|
||||
# No `with` → lifespan/startup events do not run (no scheduler/SLMM threads).
|
||||
c = TestClient(app)
|
||||
yield c
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_portal_lockout():
|
||||
"""Portal lockout state is a module-global dict; clear it between tests so
|
||||
one test's failed attempts can't lock out another."""
|
||||
try:
|
||||
import backend.portal_auth as _pa
|
||||
if hasattr(_pa, "_failures"):
|
||||
_pa._failures.clear()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
|
||||
|
||||
def make_project(db_session, name=None, **kwargs):
|
||||
"""Insert and return a Project with a unique name."""
|
||||
p = models.Project(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name or f"Proj {uuid.uuid4().hex[:8]}",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
**kwargs,
|
||||
)
|
||||
db_session.add(p)
|
||||
db_session.commit()
|
||||
return p
|
||||
@@ -0,0 +1,23 @@
|
||||
from backend.auth_passwords import hash_password, verify_password, generate_password
|
||||
|
||||
|
||||
def test_hash_is_not_plaintext_and_verifies():
|
||||
h = hash_password("hunter2")
|
||||
assert h != "hunter2"
|
||||
assert h.startswith("$argon2")
|
||||
assert verify_password("hunter2", h) is True
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_password():
|
||||
h = hash_password("hunter2")
|
||||
assert verify_password("nope", h) is False
|
||||
|
||||
|
||||
def test_verify_is_safe_on_garbage_hash():
|
||||
assert verify_password("anything", "not-a-real-hash") is False
|
||||
|
||||
|
||||
def test_generated_password_is_strong_and_unique():
|
||||
a, b = generate_password(), generate_password()
|
||||
assert a != b
|
||||
assert len(a) >= 12
|
||||
@@ -0,0 +1,16 @@
|
||||
import importlib
|
||||
from tests.conftest import make_project
|
||||
from backend.auth_passwords import hash_password
|
||||
|
||||
|
||||
def test_cookie_secure_flag_is_applied(monkeypatch, client, db_session):
|
||||
import backend.portal_auth as pa
|
||||
monkeypatch.setattr(pa, "COOKIE_SECURE", True, raising=False)
|
||||
# also patch the name imported into the router module
|
||||
import backend.routers.portal as pr
|
||||
monkeypatch.setattr(pr, "COOKIE_SECURE", True, raising=False)
|
||||
|
||||
make_project(db_session, portal_enabled=True, portal_link_token="ts",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
r = client.post("/portal/p/ts", data={"password": "pw"}, follow_redirects=False)
|
||||
assert "secure" in r.headers.get("set-cookie", "").lower()
|
||||
@@ -0,0 +1,40 @@
|
||||
from tests.conftest import make_project
|
||||
from backend.models import Project
|
||||
|
||||
|
||||
def test_enable_creates_link_token_and_reports_state(client, db_session):
|
||||
p = make_project(db_session)
|
||||
r = client.post(f"/projects/{p.id}/portal-access/enable")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["enabled"] is True
|
||||
assert body["link_url"].endswith(f"/portal/p/{db_session.get(Project, p.id).portal_link_token}")
|
||||
|
||||
|
||||
def test_set_password_returns_raw_once_and_stores_hash(client, db_session):
|
||||
p = make_project(db_session)
|
||||
client.post(f"/projects/{p.id}/portal-access/enable")
|
||||
r = client.post(f"/projects/{p.id}/portal-access/password")
|
||||
assert r.status_code == 200
|
||||
raw = r.json()["password"]
|
||||
assert len(raw) >= 12
|
||||
fresh = db_session.get(Project, p.id)
|
||||
assert fresh.portal_password_hash and fresh.portal_password_hash != raw
|
||||
|
||||
|
||||
def test_disable_turns_off_and_rotates_token(client, db_session):
|
||||
p = make_project(db_session)
|
||||
client.post(f"/projects/{p.id}/portal-access/enable")
|
||||
old = db_session.get(Project, p.id).portal_link_token
|
||||
r = client.post(f"/projects/{p.id}/portal-access/disable")
|
||||
assert r.status_code == 200
|
||||
fresh = db_session.get(Project, p.id)
|
||||
assert fresh.portal_enabled is False
|
||||
assert fresh.portal_link_token != old
|
||||
|
||||
|
||||
def test_get_state(client, db_session):
|
||||
p = make_project(db_session)
|
||||
r = client.get(f"/projects/{p.id}/portal-access")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"enabled": False, "has_password": False, "link_url": None}
|
||||
@@ -0,0 +1,46 @@
|
||||
import time
|
||||
from tests.conftest import make_project
|
||||
from backend import portal_auth as pa
|
||||
from backend.models import Client, ClientAccessToken
|
||||
|
||||
|
||||
def test_portal_client_for_project_is_1to1_and_idempotent(db_session):
|
||||
p = make_project(db_session)
|
||||
c1 = pa.portal_client_for_project(p, db_session)
|
||||
c2 = pa.portal_client_for_project(p, db_session)
|
||||
assert isinstance(c1, Client) and c1.id == c2.id
|
||||
assert c1.slug == f"portal-{p.id}"
|
||||
assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1
|
||||
# the project must be linked to its portal client, or client-scoped routes find nothing
|
||||
assert p.client_id == c1.id
|
||||
|
||||
|
||||
def test_mint_portal_session_returns_usable_token_id(db_session):
|
||||
p = make_project(db_session)
|
||||
tid = pa.mint_portal_session(p, db_session)
|
||||
tok = db_session.query(ClientAccessToken).filter_by(id=tid, revoked_at=None).first()
|
||||
assert tok is not None
|
||||
cookie = pa.make_session_cookie(tid)
|
||||
client = pa.client_from_cookie(cookie, db_session)
|
||||
assert client is not None and client.slug == f"portal-{p.id}"
|
||||
|
||||
|
||||
def test_resolve_project_by_link_token(db_session):
|
||||
p = make_project(db_session, portal_enabled=True, portal_link_token="tok-abc")
|
||||
assert pa.resolve_project_by_link_token("tok-abc", db_session).id == p.id
|
||||
assert pa.resolve_project_by_link_token("nope", db_session) is None
|
||||
|
||||
|
||||
def test_resolve_project_ignores_disabled_portal(db_session):
|
||||
make_project(db_session, portal_enabled=False, portal_link_token="tok-off")
|
||||
assert pa.resolve_project_by_link_token("tok-off", db_session) is None
|
||||
|
||||
|
||||
def test_lockout_after_max_attempts():
|
||||
pa.clear_failures("k1")
|
||||
assert pa.is_locked("k1") is False
|
||||
for _ in range(pa.MAX_ATTEMPTS):
|
||||
pa.register_failure("k1")
|
||||
assert pa.is_locked("k1") is True
|
||||
pa.clear_failures("k1")
|
||||
assert pa.is_locked("k1") is False
|
||||
@@ -0,0 +1,60 @@
|
||||
from tests.conftest import make_project
|
||||
from backend import portal_auth as pa
|
||||
from backend.auth_passwords import hash_password
|
||||
|
||||
|
||||
def _enabled_project(db_session, token="tok-1", password="secretpw"):
|
||||
return make_project(db_session, portal_enabled=True, portal_link_token=token,
|
||||
portal_password_hash=hash_password(password))
|
||||
|
||||
|
||||
def test_get_prompt_renders_for_valid_token(client, db_session):
|
||||
_enabled_project(db_session)
|
||||
r = client.get("/portal/p/tok-1")
|
||||
assert r.status_code == 200
|
||||
assert "password" in r.text.lower()
|
||||
|
||||
|
||||
def test_get_unknown_token_shows_generic_page(client, db_session):
|
||||
r = client.get("/portal/p/does-not-exist")
|
||||
assert r.status_code in (403, 404)
|
||||
assert "password" not in r.text.lower() or "isn't valid" in r.text.lower()
|
||||
|
||||
|
||||
def test_wrong_password_is_rejected(client, db_session):
|
||||
_enabled_project(db_session, password="rightpw")
|
||||
r = client.post("/portal/p/tok-1", data={"password": "wrongpw"}, follow_redirects=False)
|
||||
assert r.status_code == 200 # re-renders the form, no cookie
|
||||
assert "portal_session" not in r.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
def test_correct_password_sets_cookie_and_redirects(client, db_session):
|
||||
_enabled_project(db_session, password="rightpw")
|
||||
r = client.post("/portal/p/tok-1", data={"password": "rightpw"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/portal"
|
||||
assert "portal_session=" in r.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
def test_lockout_after_five_wrong(client, db_session):
|
||||
_enabled_project(db_session, token="tok-lock", password="rightpw")
|
||||
for _ in range(5):
|
||||
client.post("/portal/p/tok-lock", data={"password": "x"}, follow_redirects=False)
|
||||
# 6th attempt — even the CORRECT password is refused while locked
|
||||
r = client.post("/portal/p/tok-lock", data={"password": "rightpw"}, follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "portal_session=" not in r.headers.get("set-cookie", "")
|
||||
assert "too many" in r.text.lower()
|
||||
|
||||
|
||||
def test_enabled_without_password_is_not_accessible(client, db_session):
|
||||
# enabled portal but no password set yet (operator enabled before generating one)
|
||||
# must NOT show a usable form — looks like an invalid link, no self-lockout.
|
||||
make_project(db_session, portal_enabled=True, portal_link_token="tok-nopw")
|
||||
r = client.get("/portal/p/tok-nopw")
|
||||
assert r.status_code == 404
|
||||
assert "isn't valid" in r.text.lower()
|
||||
# and a POST can't succeed or set a cookie either
|
||||
r2 = client.post("/portal/p/tok-nopw", data={"password": "anything"}, follow_redirects=False)
|
||||
assert r2.status_code == 404
|
||||
assert "portal_session=" not in r2.headers.get("set-cookie", "")
|
||||
@@ -0,0 +1,29 @@
|
||||
import sqlite3
|
||||
import importlib
|
||||
|
||||
|
||||
def _columns(db_file):
|
||||
conn = sqlite3.connect(db_file)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
conn.close()
|
||||
return cols
|
||||
|
||||
|
||||
def test_migration_adds_columns_and_is_idempotent(tmp_path, monkeypatch):
|
||||
db_file = tmp_path / "seismo_fleet.db"
|
||||
conn = sqlite3.connect(db_file)
|
||||
conn.execute("CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.chdir(tmp_path) # migration resolves data/ relative to cwd
|
||||
(tmp_path / "data").mkdir()
|
||||
(tmp_path / "data" / "seismo_fleet.db").write_bytes(db_file.read_bytes())
|
||||
|
||||
mod = importlib.import_module("backend.migrate_add_project_portal_auth")
|
||||
mod.migrate()
|
||||
cols = _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= cols
|
||||
|
||||
mod.migrate() # second run must not raise
|
||||
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||
@@ -0,0 +1,81 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.testclient import WebSocketDisconnect
|
||||
from tests.conftest import make_project
|
||||
from backend import portal_auth as pa
|
||||
from backend.auth_passwords import hash_password
|
||||
from backend.models import MonitoringLocation
|
||||
|
||||
|
||||
def _sound_location(db_session, project):
|
||||
loc = MonitoringLocation(
|
||||
id=str(uuid.uuid4()), project_id=project.id, name="Site",
|
||||
location_type="sound", created_at=datetime.utcnow(),
|
||||
sort_order=0)
|
||||
db_session.add(loc)
|
||||
db_session.commit()
|
||||
return loc
|
||||
|
||||
|
||||
def test_session_for_A_cannot_open_B_location(client, db_session):
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="ta",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
b = make_project(db_session)
|
||||
b_loc = _sound_location(db_session, b)
|
||||
|
||||
# Establish an A session
|
||||
r = client.post("/portal/p/ta", data={"password": "pw"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
# Try to open B's location page → 404 (not 403), no leak
|
||||
r2 = client.get(f"/portal/location/{b_loc.id}")
|
||||
assert r2.status_code == 404
|
||||
|
||||
|
||||
def test_session_can_open_its_own_location(client, db_session):
|
||||
# Positive case: proves the negative test's 404 is real scoping, not a blanket
|
||||
# "client owns nothing" failure — an A session CAN open A's own location.
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="ta2",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
a_loc = _sound_location(db_session, a)
|
||||
r = client.post("/portal/p/ta2", data={"password": "pw"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
r2 = client.get(f"/portal/location/{a_loc.id}")
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
def test_ws_stream_rejects_unauthenticated(client, db_session):
|
||||
# The live-feed WebSocket must refuse a connection with no session cookie (1008).
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="tw1",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
a_loc = _sound_location(db_session, a)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
|
||||
ws.receive_text()
|
||||
assert exc.value.code == 1008
|
||||
|
||||
|
||||
def test_ws_stream_rejects_cross_project(client, db_session, monkeypatch):
|
||||
# The WebSocket enforces the SAME per-project ownership as the HTTP routes: a
|
||||
# B-session opening A's stream is closed 1008 (ownership) before any device feed.
|
||||
# The handler uses SessionLocal() directly (not the get_db override), so point it
|
||||
# at the test DB engine so this genuinely exercises the ownership check (not a
|
||||
# vacuous "client not found").
|
||||
import backend.routers.portal as portal_router
|
||||
monkeypatch.setattr(portal_router, "SessionLocal",
|
||||
sessionmaker(bind=db_session.get_bind()))
|
||||
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="tw2",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
a_loc = _sound_location(db_session, a)
|
||||
make_project(db_session, portal_enabled=True, portal_link_token="tw3",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
# Log in as project B, then aim the stream at project A's location.
|
||||
assert client.post("/portal/p/tw3", data={"password": "pw"},
|
||||
follow_redirects=False).status_code == 303
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
|
||||
ws.receive_text()
|
||||
assert exc.value.code == 1008
|
||||
@@ -0,0 +1,20 @@
|
||||
from tests.conftest import make_project
|
||||
|
||||
|
||||
def test_enter_and_open_are_gone(client, db_session):
|
||||
assert client.get("/portal/enter/anything", follow_redirects=False).status_code == 404
|
||||
assert client.get("/portal/open/anything", follow_redirects=False).status_code == 404
|
||||
|
||||
|
||||
def test_portal_link_endpoints_are_gone(client, db_session):
|
||||
p = make_project(db_session)
|
||||
assert client.post(f"/projects/{p.id}/portal-link").status_code == 404
|
||||
assert client.get(f"/projects/{p.id}/portal-links").status_code == 404
|
||||
assert client.post(f"/projects/{p.id}/portal-link/sometoken/revoke").status_code == 404
|
||||
|
||||
|
||||
def test_preview_still_mints_a_session(client, db_session):
|
||||
p = make_project(db_session)
|
||||
r = client.get(f"/projects/{p.id}/portal-preview", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert "portal_session=" in r.headers.get("set-cookie", "")
|
||||
Reference in New Issue
Block a user