3 Commits

Author SHA1 Message Date
serversdown d0685baed5 Merge pull request 'v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes' (#54) from dev into main
Reviewed-on: #54
docs+chore: v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes

CHANGELOG entry for the five commits that landed after the v0.12.0 tag:
two features (Unit Swap wizard at /tools/unit-swap, editable deployment
timeline on /unit/{id}) and two correctness fixes (RosterUnit.deployed
now flips on swap/unassign/promote; deployment timeline now respects
user timezone for both display and edits).  No schema migrations.

README bumped to v0.12.1 with new bullets for the post-v0.12.0 features
and several already-shipped items that were missing from the list (SFM
Event DB Manager, Deployment-History calendar + Gantt tabs, reusable
location-map partial).  backend/main.py VERSION constant bumped too.
2026-05-20 11:44:47 -04:00
serversdown 275a168046 Merge pull request 'merge v0.12.0' (#51) from dev into main
Reviewed-on: #51
2026-05-17 19:44:56 -04:00
serversdown f4fd1c943d Merge pull request 'v0.11.0' (#50) from release/0.11.0 into main
## [0.11.0] - 2026-05-15

Operator-facing polish release.  All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.

### Added
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events.  Cascade-closes active unit assignments and cancels pending scheduled actions at the location.  Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming).  Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page.  Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups.  Click a bar to scroll the matching list row into view with a flash highlight.
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record).  Now auto-detected and surfaceable in the timeline header — one click combines them into a single record.  Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment.  Trash icon in each row of the location's Deployment History panel.  Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history.  Writes an `assignment_deleted` UnitHistory row.
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left.  Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`.  Implementation uses native HTML5 drag-and-drop (no library).  New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu.  Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline).  Sound locations still show session counts.
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map.  One pin per active monitoring location (parsed from the `coordinates` field).  Click pin → scrolls + flashes the matching card.  Tooltip on hover.  Locations without coordinates surface as an inline hint below the map.  If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.

### Changed
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores.  Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio.  Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens.  Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90.  Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).

### Fixed
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler.  Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
    - **Location Remove button** on the project page
    - **Metadata-backfill typeahead dropdown** (existing project + location pickers)
    - **Project-merge typeahead dropdown** (in the per-project header)
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`.  Fixed by adding `isolation: isolate` to the map container.
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`.  Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.

### Migration Notes
Run on each database before deploying.  Both migrations are idempotent and non-destructive.

```bash
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
```

Or sweep all migrations at once (safe — already-applied ones no-op):

```bash
for f in backend/migrate_*.py; do
  docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```

New columns added this release:
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
- `monitoring_locations.removal_reason` (TEXT, nullable)
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration

**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns.  Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.

---
2026-05-15 19:16:42 -04:00
47 changed files with 347 additions and 4538 deletions
-225
View File
@@ -5,231 +5,6 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
SLM live monitoring — fan-out feed + cache-first reads. Targets **0.14.0**. The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed. This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand. Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.
### Added
- **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
- **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0.
- **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
- **Live Measurements panel auto-populates from cache.** Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit. Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale). Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
- **Refresh buttons** — one per device-list row, one in the panel header. On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.
### Changed
- **Dashboard device list + command center read SLMM's cache, not the device.** `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load. This stops dashboard loads from stealing the device's single connection from the live monitor. The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
- **Device-list freshness reflects real monitoring.** The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates. The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.
### Fixed
- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.** `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`. The deploy POST had already succeeded, so the operator saw both the green success **and** a red error. Both call sites now guard on the element existing (`slm_settings_modal.html`).
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.
### Upgrade Notes
Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.
```bash
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py
# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
```
The two builds must ship **together**. Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.
---
### Client portal *(new — read-only client-facing view)*
A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
locations, live. Built inside Terra-View (no new service), reusing the cached
SLMM feed; every route resolves the client through one swappable
`get_current_client` gate, so the interim magic/open-link auth can be replaced
(M4) without touching routes or templates. Strictly read-only — no device control.
#### Added
- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a
`Project.client_id` FK. A signed (HMAC) session cookie carries the access-token
id, re-validated against the DB each request (revoke kills live sessions, with
server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a
dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream
**auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
- **Locations overview.** Live status map (level-colored dots, dark/light CARTO
tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the
headline metric.
- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail
page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
in the portal; enabling a rule pins that device's monitor on so alerts evaluate
round-the-clock.
- **Operator sharing tools.** A **"View client portal"** preview button and a
**"Copy client link"** modal (mint / list / revoke magic links) on the project
page, plus a `backend/portal_admin.py` CLI.
- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI +
IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
**light/dark toggle** (light default, persisted, no-flash).
#### Security
- All scoping enforced server-side (404-not-403, no existence leak); client
endpoints return **scrubbed** projections (no device-health/internal ids); WS
frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
Pre-merge code review hardened cookie expiry, open-links default, and the slug
collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".
#### Upgrade Notes
- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
(adds `projects.client_id`; the `clients` / `client_access_tokens` tables
auto-create).
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
and keep **`PORTAL_OPEN_LINKS=false`** there.
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
cooldown + keepalive coupling) — same build pairing as above.
---
## [0.13.3] - 2026-06-05
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
### Added
- **Calibration sync service** (`backend/services/calibration_sync.py`). Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`. Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
- **Conflict rule: events-as-truth, manual wins when newer.** Three outcomes per unit:
- `already_in_sync` — stored date already matches the event's calibration date.
- `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved. Only a future event can supersede it.
- `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`). Started in `main.py`'s startup hook, stopped on shutdown. Does not run on boot — first sync after a server start fires at the next 03:15.
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`. Powers the Settings button.
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows. Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab). Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist. Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.
### Fixed
- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**. The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it. Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.
### Upgrade Notes
No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist. Rebuild only:
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```
After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.
---
## [0.13.2] - 2026-05-30
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
### Fixed
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
### What mobile users will see after deploy
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
---
## [0.13.1] - 2026-05-29
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
### Fixed
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
### Migration Notes
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
```
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
---
## [0.13.0] - 2026-05-29
The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
### Added — Event-detail modal: Chart.js waveform/histogram panels
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
### Added — Event-detail modal: PDF preview + downloads + review form
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
### Added — `/admin/events` row click opens the modal
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
### Added — `mic_unit_pref` user setting (Settings → General)
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
### Fixed — Docker Compose: SFM container can finally read the DB
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
### Migration Notes
```bash
cd /home/serversdown/terra-view
# Apply the new column to the database — required. Idempotent.
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
docker compose build terra-view && docker compose up -d
```
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
### What's NOT in this release
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
---
## [0.12.1] - 2026-05-20 ## [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. 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 -1
View File
@@ -1,4 +1,4 @@
# Terra-View v0.13.3 # Terra-View v0.12.1
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features
+3 -109
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.13.3" VERSION = "0.12.1"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":
@@ -66,21 +66,6 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Use shared templates configuration with timezone filters # Use shared templates configuration with timezone filters
from backend.templates_config import templates from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client).
from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS
@app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError):
if request.url.path.startswith("/portal/api"):
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "required"},
status_code=401,
)
# Add custom context processor to inject environment variable into all templates # Add custom context processor to inject environment variable into all templates
@app.middleware("http") @app.middleware("http")
async def add_environment_to_context(request: Request, call_next): async def add_environment_to_context(request: Request, call_next):
@@ -112,10 +97,6 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router) app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router) app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router) app.include_router(seismo_dashboard.router)
# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md
from backend.routers import portal
app.include_router(portal.router)
app.include_router(sfm.router) app.include_router(sfm.router)
app.include_router(modem_dashboard.router) app.include_router(modem_dashboard.router)
@@ -163,14 +144,9 @@ app.include_router(fleet_calendar.router)
from backend.routers import deployments from backend.routers import deployments
app.include_router(deployments.router) app.include_router(deployments.router)
# Calibration sync router (SFM-driven cal date updates)
from backend.routers import calibration
app.include_router(calibration.router)
# Start scheduler service and device status monitor on application startup # Start scheduler service and device status monitor on application startup
from backend.services.scheduler import start_scheduler, stop_scheduler from backend.services.scheduler import start_scheduler, stop_scheduler
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
from backend.services.calibration_sync import get_calibration_sync_scheduler
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
@@ -183,10 +159,6 @@ async def startup_event():
await start_device_status_monitor() await start_device_status_monitor()
logger.info("Device status monitor started") logger.info("Device status monitor started")
logger.info("Starting calibration sync scheduler...")
get_calibration_sync_scheduler().start()
logger.info("Calibration sync scheduler started")
@app.on_event("shutdown") @app.on_event("shutdown")
def shutdown_event(): def shutdown_event():
"""Clean up services on app shutdown""" """Clean up services on app shutdown"""
@@ -198,10 +170,6 @@ def shutdown_event():
stop_scheduler() stop_scheduler()
logger.info("Scheduler service stopped") logger.info("Scheduler service stopped")
logger.info("Stopping calibration sync scheduler...")
get_calibration_sync_scheduler().stop()
logger.info("Calibration sync scheduler stopped")
# Legacy routes from the original backend # Legacy routes from the original backend
from backend import routes as legacy_routes from backend import routes as legacy_routes
@@ -409,84 +377,10 @@ async def project_detail_page(request: Request, project_id: str):
"""Project detail dashboard""" """Project detail dashboard"""
return templates.TemplateResponse("projects/detail.html", { return templates.TemplateResponse("projects/detail.html", {
"request": request, "request": request,
"project_id": project_id, "project_id": project_id
"portal_open_links": PORTAL_OPEN_LINKS,
}) })
@app.get("/projects/{project_id}/portal-preview")
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
"""Operator testing shortcut: log into the client portal scoped to this project
(auto-provisioning a client/link if needed), no CLI. Lives under /projects (not
/portal), so a public proxy that exposes only /portal/* won't expose this."""
from backend.models import Project
from backend.portal_auth import (
provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
)
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
token_id = provision_preview_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
return resp
@app.post("/projects/{project_id}/portal-link")
async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Mint a fresh shareable client link for this project's client. Returns the
full /portal/enter/<token> URL (shown once). Operator-only (internal app)."""
from backend.models import Project
from backend.portal_auth import ensure_project_client, mint_link_token
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
client = ensure_project_client(project, db)
raw = mint_link_token(client, db, label="shared link")
url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}"
return {"url": url, "client_name": client.name}
@app.get("/projects/{project_id}/portal-links")
async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)):
"""List active (non-revoked) shareable links for this project's client."""
from backend.models import Project, ClientAccessToken, Client
project = db.query(Project).filter_by(id=project_id).first()
if not project or not project.client_id:
return {"client_name": None, "links": []}
client = db.query(Client).filter_by(id=project.client_id).first()
toks = (db.query(ClientAccessToken)
.filter_by(client_id=project.client_id, revoked_at=None)
.order_by(ClientAccessToken.created_at.desc()).all())
return {
"client_name": client.name if client else None,
"links": [{
"id": t.id, "label": t.label,
"created_at": t.created_at.isoformat() if t.created_at else None,
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
} for t in toks],
}
@app.post("/projects/{project_id}/portal-link/{token_id}/revoke")
async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)):
"""Revoke one shareable link (scoped to this project's client). Kills the link
and any live session minted from it on the next request."""
from datetime import datetime as _dt
from backend.models import Project, ClientAccessToken
project = db.query(Project).filter_by(id=project_id).first()
if not project or not project.client_id:
return JSONResponse(status_code=404, content={"detail": "Not found"})
tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first()
if not tok:
return JSONResponse(status_code=404, content={"detail": "Link not found"})
if not tok.revoked_at:
tok.revoked_at = _dt.utcnow()
db.commit()
return {"ok": True}
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
async def nrl_detail_page( async def nrl_detail_page(
request: Request, request: Request,
-56
View File
@@ -1,56 +0,0 @@
#!/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()
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Database migration: Add mic_unit_pref column to user_preferences.
Adds a single field controlling the mic channel's unit on the event-
report waveform chart in the SFM event detail modal. "psi" (default —
matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles
elsewhere are always dBL regardless.
History: v0.13.0 originally shipped this with default "dBL", which
made the website chart inconsistent with the PDF. v0.13.1 flips the
default to "psi" so they match. This migration is idempotent and
covers three cases:
1. Fresh DB without the column — adds it with default 'psi'.
2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to
'psi' on the assumption no operator deliberately picked 'dBL' yet.
3. DB upgraded from later — flip step is a no-op for non-'dBL' values.
"""
import sqlite3
from pathlib import Path
def migrate():
possible_paths = [
Path("data/seismo_fleet.db"),
Path("data/sfm.db"),
Path("data/seismo.db"),
]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("Will be created with the new column when models.py initialises.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("PRAGMA table_info(user_preferences)")
existing = {row[1] for row in cur.fetchall()}
if "mic_unit_pref" not in existing:
cur.execute(
"ALTER TABLE user_preferences "
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
)
# Backfill any rows where the column ended up NULL.
cur.execute(
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
"WHERE mic_unit_pref IS NULL"
)
print("Added mic_unit_pref column (default 'psi').")
else:
print("mic_unit_pref column already exists.")
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
# 'dBL' default get bumped to 'psi'. If any operator deliberately
# chose 'dBL' through Settings before this migration runs they'd
# get reset — acceptable trade-off given the small user base and
# the fact the setting is one click to restore.
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
"WHERE mic_unit_pref = 'dBL'")
flipped = cur.rowcount
if flipped:
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
conn.commit()
conn.close()
if __name__ == "__main__":
migrate()
-40
View File
@@ -135,11 +135,6 @@ class UserPreferences(Base):
calibration_warning_days = Column(Integer, default=30) calibration_warning_days = Column(Integer, default=30)
status_ok_threshold_hours = Column(Integer, default=12) status_ok_threshold_hours = Column(Integer, default=12)
status_pending_threshold_hours = Column(Integer, default=24) status_pending_threshold_hours = Column(Integer, default=24)
# Mic display units on the event-report waveform chart only — peaks
# and KPI tiles elsewhere are always dBL. "psi" (default — matches
# the PDF report) or "dBL". Default flipped in v0.13.1 after
# operator feedback that the chart should mirror the PDF.
mic_unit_pref = Column(String, default="psi")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -192,7 +187,6 @@ class Project(Base):
# Project metadata # Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
site_address = Column(String, nullable=True) site_address = Column(String, nullable=True)
site_coordinates = Column(String, nullable=True) # "lat,lon" site_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True) start_date = Column(Date, nullable=True)
@@ -705,37 +699,3 @@ class PendingDeployment(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================================
# 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
-169
View File
@@ -1,169 +0,0 @@
#!/usr/bin/env python3
"""
Client-portal admin CLI (M1). Operator tooling — run inside the terra-view
container against the live DB. The raw magic-link token is shown ONCE on mint;
only its hash is stored.
# create a client
python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com]
# attach a project to a client (sets Project.client_id) — by id, number, or name
python3 backend/portal_admin.py link-project --slug myler --project-id <PID>
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
# mint a magic access link (FULL URL PRINTED ONCE — copy it now)
python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"]
# list clients, their projects, and active links
python3 backend/portal_admin.py list
# revoke a link (stops the link AND any live session it minted)
python3 backend/portal_admin.py revoke --token-id <TID>
The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001).
"""
import os
import sys
import uuid
import secrets
import argparse
from datetime import datetime
# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0],
# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import SessionLocal
from backend.models import Client, ClientAccessToken, Project
from backend.portal_auth import hash_token
PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/")
def _get_client(db, slug):
c = db.query(Client).filter_by(slug=slug).first()
if not c:
sys.exit(f"No client with slug '{slug}'. Create it first.")
return c
def create_client(args):
db = SessionLocal()
try:
if db.query(Client).filter_by(slug=args.slug).first():
sys.exit(f"A client with slug '{args.slug}' already exists.")
c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug,
contact_email=args.email, active=True)
db.add(c)
db.commit()
print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})")
print(" Next: link-project, then mint-link.")
finally:
db.close()
def link_project(args):
db = SessionLocal()
try:
c = _get_client(db, args.slug)
q = db.query(Project)
if args.project_id:
p = q.filter_by(id=args.project_id).first()
elif args.project_number:
p = q.filter_by(project_number=args.project_number).first()
elif args.project_name:
p = q.filter_by(name=args.project_name).first()
else:
sys.exit("Specify --project-id, --project-number, or --project-name.")
if not p:
sys.exit("Project not found.")
p.client_id = c.id
db.commit()
print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'")
finally:
db.close()
def mint_link(args):
db = SessionLocal()
try:
c = _get_client(db, args.slug)
raw = secrets.token_urlsafe(32)
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id,
token_hash=hash_token(raw), label=args.label)
db.add(tok)
db.commit()
print(f"✓ Minted access link for '{c.name}'"
f"{f' ({args.label})' if args.label else ''} — token id {tok.id}")
print("\n COPY THIS NOW (shown only once):\n")
print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n")
finally:
db.close()
def revoke(args):
db = SessionLocal()
try:
tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first()
if not tok:
sys.exit("No token with that id.")
if tok.revoked_at:
print("○ Already revoked.")
return
tok.revoked_at = datetime.utcnow()
db.commit()
print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.")
finally:
db.close()
def list_all(args):
db = SessionLocal()
try:
clients = db.query(Client).order_by(Client.name).all()
if not clients:
print("No clients yet.")
return
for c in clients:
state = "" if c.active else " [INACTIVE]"
print(f"\n{c.name} (slug={c.slug}){state}")
projs = db.query(Project).filter_by(client_id=c.id).all()
print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)"))
toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all()
if not toks:
print(" links: (none — run mint-link)")
for t in toks:
status = "revoked" if t.revoked_at else "active"
last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used"
print(f" link {t.id} [{status}] {t.label or ''} (last: {last})")
print()
finally:
db.close()
def main():
ap = argparse.ArgumentParser(description="Client-portal admin (M1)")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("create-client"); p.add_argument("--name", required=True)
p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client)
p = sub.add_parser("link-project"); p.add_argument("--slug", required=True)
p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name")
p.set_defaults(fn=link_project)
p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True)
p.add_argument("--label"); p.set_defaults(fn=mint_link)
p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke)
p = sub.add_parser("list"); p.set_defaults(fn=list_all)
args = ap.parse_args()
args.fn(args)
if __name__ == "__main__":
main()
-183
View File
@@ -1,183 +0,0 @@
"""
Client-portal auth — the swappable gate (see docs/CLIENT_PORTAL.md).
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
mints a signed session cookie. Every portal route depends on get_current_client();
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
against the DB on every request, so revoking a link (revoked_at) kills its live
sessions on the next request — not just future clicks.
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
"""
import os
import hmac
import json
import time
import uuid
import base64
import hashlib
import logging
import secrets
from datetime import datetime
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Client, ClientAccessToken
logger = logging.getLogger(__name__)
# Signing secret for portal session cookies. MUST be set to a real secret in prod
# (env). The insecure default only exists so dev/test boots without config.
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
if SECRET_KEY == "dev-insecure-change-me":
logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")
COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
# Plain, no-token portal links (/portal/open/{project_id}). These are an
# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's
# open link grants the *whole* client's scope), so they default OFF and must be
# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env.
PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes")
if PORTAL_OPEN_LINKS:
logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
"Keep this OFF in any internet-facing / production deployment.")
class PortalAuthError(Exception):
"""Raised by get_current_client when there's no valid portal session.
Handled centrally in main.py: HTML routes get the access-required page,
/portal/api/* routes get a 401 JSON."""
# -- token + cookie primitives ----------------------------------------------
def hash_token(raw: str) -> str:
"""sha256 hex of a raw access-token secret (what we store + look up by)."""
return hashlib.sha256(raw.encode()).hexdigest()
def _sign(body: str) -> str:
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
def make_session_cookie(token_id: str) -> str:
body = base64.urlsafe_b64encode(
json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
).decode()
return f"{body}.{_sign(body)}"
def _read_session_cookie(value: str):
"""Return the token id from a signed cookie, or None if missing/tampered."""
try:
body, sig = value.rsplit(".", 1)
except (ValueError, AttributeError):
return None
if not hmac.compare_digest(sig, _sign(body)):
return None
try:
data = json.loads(base64.urlsafe_b64decode(body.encode()))
if not isinstance(data, dict):
return None
# Server-side expiry: a leaked cookie isn't valid forever (max_age is only a
# browser hint). iat is set by make_session_cookie.
iat = data.get("iat")
if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE:
return None
return data.get("tid")
except Exception:
return None
# -- the dependency every portal route uses ---------------------------------
def client_from_cookie(cookie_value, db: Session):
"""Resolve a Client from a raw session-cookie value, or None. Re-validates the
access token against the DB each call, so a revoked link / disabled client
drops immediately. Shared by the HTTP dependency and the WebSocket handler
(which can't use Request-based Depends)."""
token_id = _read_session_cookie(cookie_value) if cookie_value else None
if not token_id:
return None
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
if not tok:
return None
return db.query(Client).filter_by(id=tok.client_id, active=True).first()
def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
"""Resolve the authenticated client, or raise PortalAuthError."""
client = client_from_cookie(request.cookies.get(COOKIE_NAME), db)
if client is None:
raise PortalAuthError()
return client
def resolve_token(raw_token: str, db: Session):
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on
success, or (None, None). Also stamps last_used_at."""
tok = db.query(ClientAccessToken).filter_by(
token_hash=hash_token(raw_token), revoked_at=None
).first()
if not tok:
return None, None
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
if not client:
return None, None
tok.last_used_at = datetime.utcnow()
db.commit()
return tok, client
def ensure_project_client(project, db) -> Client:
"""Find or create the Client for a project. Reuses the project's linked client
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
sets project.client_id (only when unset, so it never clobbers a real link)."""
client = None
if project.client_id:
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
if client is None:
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
client = db.query(Client).filter_by(slug=slug).first()
if client is None:
client = Client(id=str(uuid.uuid4()),
name=(project.client_name or project.name or "Preview"),
slug=slug, active=True)
db.add(client)
db.flush()
if not project.client_id:
project.client_id = client.id
return client
def mint_link_token(client, db, label=None) -> str:
"""Mint a fresh access token for a client and return the RAW secret (caller
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored."""
raw = secrets.token_urlsafe(32)
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(raw), label=label))
db.commit()
return raw
def provision_preview_session(project, db) -> str:
"""Operator preview shortcut: ensure a Client + access token exist for a project
and return a token id to seal into a session cookie (no shared link). Reuses an
existing token so repeat previews don't accumulate clutter; the raw secret is
discarded (preview rides the cookie)."""
client = ensure_project_client(project, db)
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
if tok is None:
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(secrets.token_urlsafe(32)),
label="preview")
db.add(tok)
db.commit()
return tok.id
+1 -3
View File
@@ -9,7 +9,6 @@ import logging
import httpx import httpx
from backend.database import get_db from backend.database import get_db
from backend.models import UnitHistory, Emitter, RosterUnit from backend.models import UnitHistory, Emitter, RosterUnit
from backend.services.unit_location import get_active_location
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -141,7 +140,6 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
days = int(hours_ago / 24) days = int(hours_ago / 24)
time_ago = f"{days}d ago" time_ago = f"{days}d ago"
loc = get_active_location(db, emitter.id) if roster_unit else None
call_in = { call_in = {
"unit_id": emitter.id, "unit_id": emitter.id,
"last_seen": emitter.last_seen.isoformat(), "last_seen": emitter.last_seen.isoformat(),
@@ -150,7 +148,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
"device_type": roster_unit.device_type if roster_unit else "seismograph", "device_type": roster_unit.device_type if roster_unit else "seismograph",
"deployed": roster_unit.deployed if roster_unit else False, "deployed": roster_unit.deployed if roster_unit else False,
"note": roster_unit.note if roster_unit and roster_unit.note else "", "note": roster_unit.note if roster_unit and roster_unit.note else "",
"location": (loc or {}).get("address") or (loc or {}).get("name") or "" "location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
} }
call_ins.append(call_in) call_ins.append(call_in)
-31
View File
@@ -1,31 +0,0 @@
"""
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()
+3 -5
View File
@@ -750,17 +750,15 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
# Last seen from emitter # Last seen from emitter
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first() emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
from backend.services.unit_location import get_active_location
loc = get_active_location(db, u.id)
return { return {
"id": u.id, "id": u.id,
"unit_type": u.unit_type, "unit_type": u.unit_type,
"deployed": u.deployed, "deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False, "out_for_calibration": u.out_for_calibration or False,
"note": u.note or "", "note": u.note or "",
"project_id": (loc or {}).get("project_id") or u.project_id or "", "project_id": u.project_id or "",
"address": (loc or {}).get("address") or "", "address": u.address or u.location or "",
"coordinates": (loc or {}).get("coordinates") or "", "coordinates": u.coordinates or "",
"deployed_with_modem_id": u.deployed_with_modem_id or "", "deployed_with_modem_id": u.deployed_with_modem_id or "",
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None), "next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
+10 -12
View File
@@ -14,7 +14,6 @@ import logging
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,7 +85,8 @@ async def get_modem_units(
(RosterUnit.id.ilike(search_term)) | (RosterUnit.id.ilike(search_term)) |
(RosterUnit.ip_address.ilike(search_term)) | (RosterUnit.ip_address.ilike(search_term)) |
(RosterUnit.hardware_model.ilike(search_term)) | (RosterUnit.hardware_model.ilike(search_term)) |
(RosterUnit.phone_number.ilike(search_term)) (RosterUnit.phone_number.ilike(search_term)) |
(RosterUnit.location.ilike(search_term))
) )
modems = query.order_by( modems = query.order_by(
@@ -128,8 +128,6 @@ async def get_modem_units(
if filter_status and status != filter_status: if filter_status and status != filter_status:
continue continue
# Inherit location from the paired device's active assignment.
loc = get_active_location(db, modem.id) if paired else None
modem_list.append({ modem_list.append({
"id": modem.id, "id": modem.id,
"ip_address": modem.ip_address, "ip_address": modem.ip_address,
@@ -137,8 +135,8 @@ async def get_modem_units(
"hardware_model": modem.hardware_model, "hardware_model": modem.hardware_model,
"deployed": modem.deployed, "deployed": modem.deployed,
"retired": modem.retired, "retired": modem.retired,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": modem.location,
"project_id": (loc or {}).get("project_id") or modem.project_id, "project_id": modem.project_id,
"paired_device": paired, "paired_device": paired,
"status": status "status": status
}) })
@@ -167,15 +165,14 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
).first() ).first()
if device: if device:
loc = get_active_location(db, device.id)
return { return {
"paired": True, "paired": True,
"device": { "device": {
"id": device.id, "id": device.id,
"device_type": device.device_type, "device_type": device.device_type,
"deployed": device.deployed, "deployed": device.deployed,
"project_id": (loc or {}).get("project_id") or device.project_id, "project_id": device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "" "location": device.location or device.address
} }
} }
@@ -317,6 +314,8 @@ async def get_pairable_devices(
query = query.filter( query = query.filter(
(RosterUnit.id.ilike(search_term)) | (RosterUnit.id.ilike(search_term)) |
(RosterUnit.project_id.ilike(search_term)) | (RosterUnit.project_id.ilike(search_term)) |
(RosterUnit.location.ilike(search_term)) |
(RosterUnit.address.ilike(search_term)) |
(RosterUnit.note.ilike(search_term)) (RosterUnit.note.ilike(search_term))
) )
@@ -339,13 +338,12 @@ async def get_pairable_devices(
if hide_paired and is_paired_to_other: if hide_paired and is_paired_to_other:
continue continue
loc = get_active_location(db, device.id)
device_list.append({ device_list.append({
"id": device.id, "id": device.id,
"device_type": device.device_type, "device_type": device.device_type,
"deployed": device.deployed, "deployed": device.deployed,
"project_id": (loc or {}).get("project_id") or device.project_id, "project_id": device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": device.location or device.address,
"note": device.note, "note": device.note,
"paired_modem_id": device.deployed_with_modem_id, "paired_modem_id": device.deployed_with_modem_id,
"is_paired_to_this": is_paired_to_this, "is_paired_to_this": is_paired_to_this,
-358
View File
@@ -1,358 +0,0 @@
"""
Client portal read-only, scoped client view (see docs/CLIENT_PORTAL.md).
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
session cookie, then sees their locations (overview) and per-location read-only
live data sourced from SLMM's cache. Every data route re-checks ownership.
"""
import os
import json
import asyncio
import logging
from datetime import datetime
import httpx
import websockets
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket
from fastapi.responses import RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from backend.database import get_db, SessionLocal
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
from backend.templates_config import templates
from backend.portal_auth import (
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
provision_preview_session, PORTAL_OPEN_LINKS,
COOKIE_NAME, COOKIE_MAX_AGE,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/portal", tags=["portal"])
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
# Whitelist of fields the portal exposes to a client — sound metrics + run state
# only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed.
_PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time",
"lp", "leq", "lmax", "lpeak", "ln1", "ln2")
# -- scoping (every data route gates through these) --------------------------
def _client_project_ids(client: Client, db: Session) -> list:
return [r[0] for r in db.query(Project.id).filter(
Project.client_id == client.id, Project.status != "deleted").all()]
def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation:
"""Ownership gate: location must be a sound location in one of the client's
active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so
we never leak whether a location exists."""
loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first()
if (not loc or loc.location_type != "sound"
or loc.project_id not in _client_project_ids(client, db)):
raise HTTPException(status_code=404, detail="Location not found")
return loc
def active_unit_for_location(location_id: str, db: Session):
"""The SLM unit currently assigned to this location, or None."""
now = datetime.utcnow()
asg = (db.query(UnitAssignment)
.filter(UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.device_type == "slm",
or_(UnitAssignment.assigned_until.is_(None),
UnitAssignment.assigned_until > now))
.order_by(UnitAssignment.assigned_at.desc()).first())
return asg.unit_id if asg else None
def _client_locations(client: Client, db: Session) -> list:
"""The client's active sound locations (for the overview tiles + map)."""
pids = _client_project_ids(client, db)
if not pids:
return []
projs = {p.id: p.name for p in
db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()}
locs = (db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id.in_(pids),
MonitoringLocation.location_type == "sound",
MonitoringLocation.removed_at.is_(None))
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all())
return [{
"id": loc.id, "name": loc.name,
"address": loc.address, "coordinates": loc.coordinates,
"project_name": projs.get(loc.project_id),
"has_device": active_unit_for_location(loc.id, db) is not None,
} for loc in locs]
@router.get("/enter/{token}")
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
tok, client = resolve_token(token, db)
if not client:
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "invalid"},
status_code=403,
)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(
COOKIE_NAME, make_session_cookie(tok.id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
)
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
return resp
@router.get("/open/{project_id}")
def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Dev-only plain shareable link: open a project's client portal with no token
(gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback
sets the session cookie and lands on /portal. Lives under /portal so it works
through a reverse proxy that exposes only /portal/*."""
if not PORTAL_OPEN_LINKS:
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "required"},
status_code=404)
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "invalid"},
status_code=404)
token_id = provision_preview_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
return resp
@router.get("/logout")
def portal_logout():
resp = RedirectResponse(url="/portal/access", status_code=303)
resp.delete_cookie(COOKIE_NAME)
return resp
@router.get("/access")
def portal_access(request: Request):
"""Landing for an unauthenticated visitor (no valid link)."""
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "required"}
)
@router.get("")
def portal_home(request: Request, client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Client overview — their active sound locations with live tiles + a map."""
return templates.TemplateResponse(
"portal/overview.html",
{"request": request, "client": client,
"locations": _client_locations(client, db)},
)
@router.get("/location/{location_id}")
def portal_location(location_id: str, request: Request,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Read-only live view for one of the client's locations (404 if not owned)."""
loc = resolve_client_location(client, location_id, db)
return templates.TemplateResponse("portal/location.html", {
"request": request, "client": client, "location": loc,
"has_device": active_unit_for_location(location_id, db) is not None,
})
# -- scoped data (cache reads only — never hits the device) ------------------
@router.get("/api/location/{location_id}/live")
async def portal_location_live(location_id: str,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Scrubbed cached live reading for a location the client owns."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "data": None, "reason": "no_device"}
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
except Exception:
return {"status": "ok", "data": None, "reason": "unreachable"}
if r.status_code != 200:
return {"status": "ok", "data": None, "reason": "no_data"}
full = (r.json() or {}).get("data", {}) or {}
return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}}
@router.get("/api/location/{location_id}/history")
async def portal_location_history(location_id: str, hours: float = 2.0,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Cached chart trail for a location the client owns. (Trail rows are already
just timestamp + lp/leq/lmax/ln1/ln2 safe to pass through.)"""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "readings": []}
hours = max(0.1, min(hours, 48.0))
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history",
params={"hours": hours})
except Exception:
return {"status": "ok", "readings": []}
if r.status_code != 200:
return {"status": "ok", "readings": []}
raw = (r.json() or {}).get("readings", [])
fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints
return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]}
# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
"onset_value", "peak_value", "clear_at", "status")
@router.get("/api/location/{location_id}/events")
async def portal_location_events(location_id: str, limit: int = 20,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Scrubbed breach history for a location the client owns (read-only)."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "events": []}
limit = max(1, min(limit, 100))
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
params={"limit": limit})
except Exception:
return {"status": "ok", "events": []}
if r.status_code != 200:
return {"status": "ok", "events": []}
raw = (r.json() or {}).get("events", [])
events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/
# hysteresis internals).
_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s",
"schedule_start", "schedule_end", "schedule_days")
@router.get("/api/location/{location_id}/thresholds")
async def portal_location_thresholds(location_id: str,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""The active alert limits for a location the client owns (enabled rules only),
so the client can see what they're being alerted on. Read-only, scrubbed."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "rules": []}
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules")
except Exception:
return {"status": "ok", "rules": []}
if r.status_code != 200:
return {"status": "ok", "rules": []}
raw = (r.json() or {}).get("rules", [])
rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")]
return {"status": "ok", "rules": rules}
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
def _scrub_frame(raw: str):
"""Project a monitor frame down to the portal whitelist. Drops internal fields
(unit_id, raw_payload, lmin) before it reaches a client; passes control fields
(feed_status, heartbeat) + timestamp through. Returns None for a non-JSON frame
so the caller drops it rather than forwarding anything unscrubbed."""
try:
d = json.loads(raw)
except Exception:
return None
out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d}
if "timestamp" in d:
out["timestamp"] = d["timestamp"]
for ctrl in ("feed_status", "heartbeat"):
if ctrl in d:
out[ctrl] = d[ctrl]
return json.dumps(out)
@router.websocket("/api/location/{location_id}/stream")
async def portal_location_stream(websocket: WebSocket, location_id: str):
"""Live ~1Hz feed for a location the client owns. Auths via the session cookie,
enforces ownership, then bridges the unit's shared SLMM /monitor fan-out feed
to the browser (scrubbed). A viewer is just one more subscriber to the one
device feed no extra device connection."""
await websocket.accept()
# Auth + ownership on a short-lived session, then release it for the long bridge.
db = SessionLocal()
try:
client = client_from_cookie(websocket.cookies.get(COOKIE_NAME), db)
if client is None:
await websocket.close(code=1008) # policy violation (not authenticated)
return
try:
resolve_client_location(client, location_id, db)
except HTTPException:
await websocket.close(code=1008)
return
unit_id = active_unit_for_location(location_id, db)
finally:
db.close()
if not unit_id:
try:
await websocket.send_json({"feed_status": "no_device"})
finally:
await websocket.close(code=1000)
return
target = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
backend_ws = None
try:
backend_ws = await websockets.connect(target)
async def forward_to_client():
async for message in backend_ws:
frame = _scrub_frame(message)
if frame is not None:
await websocket.send_text(frame)
async def watch_client():
while True:
await websocket.receive_text()
tasks = [asyncio.ensure_future(forward_to_client()),
asyncio.ensure_future(watch_client())]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except Exception as e:
logger.warning(f"[PORTAL] stream {location_id}: {e}")
finally:
if backend_ws:
try:
await backend_ws.close()
except Exception:
pass
+1 -3
View File
@@ -1483,13 +1483,11 @@ async def get_available_units(
).distinct().all() ).distinct().all()
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids] 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 = [ available_units = [
{ {
"id": unit.id, "id": unit.id,
"device_type": unit.device_type, "device_type": unit.device_type,
"location": "", "location": unit.address or unit.location,
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type, "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
"deployed": bool(unit.deployed), "deployed": bool(unit.deployed),
} }
+61 -16
View File
@@ -12,7 +12,6 @@ from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
import uuid import uuid
from backend.services.slmm_sync import sync_slm_to_slmm from backend.services.slmm_sync import sync_slm_to_slmm
from backend.services.unit_location import get_active_location
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -183,6 +182,9 @@ async def add_roster_unit(
out_for_calibration: str = Form(None), out_for_calibration: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -247,6 +249,9 @@ async def add_roster_unit(
out_for_calibration=out_for_calibration_bool, out_for_calibration=out_for_calibration_bool,
note=note, note=note,
project_id=project_id, project_id=project_id,
location=location,
address=address,
coordinates=coordinates,
last_updated=datetime.utcnow(), last_updated=datetime.utcnow(),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated=last_cal_date, last_calibrated=last_cal_date,
@@ -268,15 +273,19 @@ async def add_roster_unit(
slm_measurement_range=slm_measurement_range if slm_measurement_range else None, slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
) )
# Auto-fill data from modem if pairing and fields are empty. # Auto-fill data from modem if pairing and fields are empty
# Location/address/coordinates now come from MonitoringLocation via the
# active UnitAssignment, so there's nothing to copy from the modem row.
if deployed_with_modem_id: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem" RosterUnit.device_type == "modem"
).first() ).first()
if modem: if modem:
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id unit.project_id = modem.project_id
if not unit.note and modem.note: if not unit.note and modem.note:
@@ -484,8 +493,6 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
if not unit: if not unit:
raise HTTPException(status_code=404, detail="Unit not found") raise HTTPException(status_code=404, detail="Unit not found")
active_loc = get_active_location(db, unit_id)
return { return {
"id": unit.id, "id": unit.id,
"device_type": unit.device_type or "seismograph", "device_type": unit.device_type or "seismograph",
@@ -497,11 +504,9 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "", "allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
"note": unit.note or "", "note": unit.note or "",
"project_id": unit.project_id or "", "project_id": unit.project_id or "",
"active_location": active_loc, "location": unit.location or "",
# Convenience fields so the unit-detail page can read the same shape "address": unit.address or "",
# whether or not there's an active assignment. "coordinates": unit.coordinates or "",
"address": (active_loc or {}).get("address") or "",
"coordinates": (active_loc or {}).get("coordinates") or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "", "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "", "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
"deployed_with_modem_id": unit.deployed_with_modem_id or "", "deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -533,6 +538,9 @@ async def edit_roster_unit(
allocated_to_project_id: str = Form(None), allocated_to_project_id: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -557,6 +565,8 @@ async def edit_roster_unit(
cascade_deployed: str = Form(None), cascade_deployed: str = Form(None),
cascade_retired: str = Form(None), cascade_retired: str = Form(None),
cascade_project: str = Form(None), cascade_project: str = Form(None),
cascade_location: str = Form(None),
cascade_coordinates: str = Form(None),
cascade_note: str = Form(None), cascade_note: str = Form(None),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
@@ -610,6 +620,9 @@ async def edit_roster_unit(
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
unit.note = note unit.note = note
unit.project_id = project_id unit.project_id = project_id
unit.location = location
unit.address = address
unit.coordinates = coordinates
unit.last_updated = datetime.utcnow() unit.last_updated = datetime.utcnow()
# Seismograph-specific fields # Seismograph-specific fields
@@ -617,15 +630,20 @@ async def edit_roster_unit(
unit.next_calibration_due = next_cal_date unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
# Auto-fill data from modem if pairing and fields are empty. # Auto-fill data from modem if pairing and fields are empty
# Location/address/coordinates live on MonitoringLocation now, nothing
# to copy across roster rows.
if deployed_with_modem_id: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem" RosterUnit.device_type == "modem"
).first() ).first()
if modem: if modem:
# Only fill if the device field is empty
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id unit.project_id = modem.project_id
if not unit.note and modem.note: if not unit.note and modem.note:
@@ -751,6 +769,26 @@ async def edit_roster_unit(
record_history(db, paired_unit.id, "project_change", "project_id", record_history(db, paired_unit.id, "project_change", "project_id",
old_paired_project or "", project_id or "", f"cascade from {unit_id}") old_paired_project or "", project_id or "", f"cascade from {unit_id}")
# Cascade address/location
if cascade_location in ['true', 'True', '1', 'yes']:
old_paired_address = paired_unit.address
old_paired_location = paired_unit.location
paired_unit.address = address
paired_unit.location = location
paired_unit.last_updated = datetime.utcnow()
if old_paired_address != address:
record_history(db, paired_unit.id, "address_change", "address",
old_paired_address or "", address or "", f"cascade from {unit_id}")
# Cascade coordinates
if cascade_coordinates in ['true', 'True', '1', 'yes']:
old_paired_coords = paired_unit.coordinates
paired_unit.coordinates = coordinates
paired_unit.last_updated = datetime.utcnow()
if old_paired_coords != coordinates:
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
# Cascade note # Cascade note
if cascade_note in ['true', 'True', '1', 'yes']: if cascade_note in ['true', 'True', '1', 'yes']:
old_paired_note = paired_unit.note old_paired_note = paired_unit.note
@@ -973,8 +1011,9 @@ async def import_csv(
- retired: Boolean - retired: Boolean
- note: Notes about the unit - note: Notes about the unit
- project_id: Project identifier - project_id: Project identifier
(Location / address / coordinates are not roster fields anymore they - location: Location description
live on the MonitoringLocation a unit is assigned to.) - address: Street address
- coordinates: GPS coordinates (lat;lon or lat,lon)
Seismograph-specific: Seismograph-specific:
- last_calibrated: Date (YYYY-MM-DD) - last_calibrated: Date (YYYY-MM-DD)
@@ -1087,6 +1126,9 @@ async def import_csv(
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note) existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id) existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow() existing_unit.last_updated = datetime.utcnow()
# Seismograph-specific fields # Seismograph-specific fields
@@ -1152,6 +1194,9 @@ async def import_csv(
retired=_parse_bool(row.get('retired', '')), retired=_parse_bool(row.get('retired', '')),
note=_get_csv_value(row, 'note', ''), note=_get_csv_value(row, 'note', ''),
project_id=_get_csv_value(row, 'project_id'), project_id=_get_csv_value(row, 'project_id'),
location=_get_csv_value(row, 'location'),
address=_get_csv_value(row, 'address'),
coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(), last_updated=datetime.utcnow(),
# Seismograph fields - auto-calc next_calibration_due from last_calibrated # Seismograph fields - auto-calc next_calibration_due from last_calibrated
last_calibrated=last_cal, last_calibrated=last_cal,
+9 -14
View File
@@ -12,7 +12,6 @@ from pathlib import Path
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
from backend.services.database_backup import DatabaseBackupService from backend.services.database_backup import DatabaseBackupService
from backend.services.unit_location import bulk_active_locations
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -22,14 +21,11 @@ def export_roster_csv(db: Session = Depends(get_db)):
"""Export all roster units to CSV""" """Export all roster units to CSV"""
units = db.query(RosterUnit).all() units = db.query(RosterUnit).all()
# Create CSV in memory. Location lives on MonitoringLocation now, so # Create CSV in memory
# we don't export legacy address/coordinates/location columns here —
# round-trip CSV editing would otherwise look like it edits unit
# location, when it can't.
output = io.StringIO() output = io.StringIO()
fieldnames = [ fieldnames = [
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired', 'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
'note', 'project_id', 'note', 'project_id', 'location', 'address', 'coordinates',
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id', 'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
'ip_address', 'phone_number', 'hardware_model' 'ip_address', 'phone_number', 'hardware_model'
] ]
@@ -46,6 +42,9 @@ def export_roster_csv(db: Session = Depends(get_db)):
'retired': 'true' if unit.retired else 'false', 'retired': 'true' if unit.retired else 'false',
'note': unit.note or '', 'note': unit.note or '',
'project_id': unit.project_id or '', 'project_id': unit.project_id or '',
'location': unit.location or '',
'address': unit.address or '',
'coordinates': unit.coordinates or '',
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '', 'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '', 'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
'deployed_with_modem_id': unit.deployed_with_modem_id or '', 'deployed_with_modem_id': unit.deployed_with_modem_id or '',
@@ -83,7 +82,6 @@ def get_table_stats(db: Session = Depends(get_db)):
def get_all_roster_units(db: Session = Depends(get_db)): def get_all_roster_units(db: Session = Depends(get_db)):
"""Get all roster units for management table""" """Get all roster units for management table"""
units = db.query(RosterUnit).order_by(RosterUnit.id).all() units = db.query(RosterUnit).order_by(RosterUnit.id).all()
active_locs = bulk_active_locations(db, units)
return [{ return [{
"id": unit.id, "id": unit.id,
@@ -92,10 +90,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
"deployed": unit.deployed, "deployed": unit.deployed,
"retired": unit.retired, "retired": unit.retired,
"note": unit.note or "", "note": unit.note or "",
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "", "project_id": unit.project_id or "",
"address": (active_locs.get(unit.id) or {}).get("address") or "", "location": unit.location or "",
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "", "address": unit.address or "",
"location_name": (active_locs.get(unit.id) or {}).get("name") or "", "coordinates": unit.coordinates or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None, "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None, "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
"deployed_with_modem_id": unit.deployed_with_modem_id or "", "deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -269,7 +267,6 @@ class PreferencesUpdate(BaseModel):
calibration_warning_days: Optional[int] = None calibration_warning_days: Optional[int] = None
status_ok_threshold_hours: Optional[int] = None status_ok_threshold_hours: Optional[int] = None
status_pending_threshold_hours: Optional[int] = None status_pending_threshold_hours: Optional[int] = None
mic_unit_pref: Optional[str] = None
@router.get("/preferences") @router.get("/preferences")
@@ -296,7 +293,6 @@ def get_preferences(db: Session = Depends(get_db)):
"calibration_warning_days": prefs.calibration_warning_days, "calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "psi",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
} }
@@ -338,7 +334,6 @@ def update_preferences(
"calibration_warning_days": prefs.calibration_warning_days, "calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "psi",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
} }
+37 -44
View File
@@ -91,43 +91,29 @@ async def get_slm_units(
one_hour_ago = datetime.utcnow() - timedelta(hours=1) one_hour_ago = datetime.utcnow() - timedelta(hours=1)
for unit in units: for unit in units:
# Legacy default from the roster field; refined from SLMM's cached status below.
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago) unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
unit.measurement_state = None
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
if include_measurement: if include_measurement:
# SLMM's /roster carries each unit's CACHED status (last_seen, async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device try:
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
# real monitoring without sending Measure? to the device (which the old if response.status_code == 200:
# /measurement-state did) and competing with DOD polling. One call covers all. return response.json().get("measurement_state")
slmm_status = {} except Exception:
try: return None
async with httpx.AsyncClient(timeout=3.0) as client: return None
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 = {}
# "Recent" = the monitor has a fresh successful read. last_seen only advances deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
# on a successful poll, so staleness == the device isn't being reached. if deployed_units:
recent_cutoff = datetime.utcnow() - timedelta(minutes=5) async with httpx.AsyncClient(timeout=3.0) as client:
for unit in units: tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
st = slmm_status.get(unit.id) results = await asyncio.gather(*tasks, return_exceptions=True)
if not st:
continue for unit, state in zip(deployed_units, results):
unit.measurement_state = st.get("measurement_state") if isinstance(state, Exception):
last_seen = st.get("last_seen") unit.measurement_state = None
if last_seen: else:
try: unit.measurement_state = state
ls = datetime.fromisoformat(last_seen.replace("Z", ""))
unit.is_recent = ls > recent_cutoff
unit.cache_last_seen = ls # the real freshness the monitor updates
except Exception:
pass
return templates.TemplateResponse("partials/slm_device_list.html", { return templates.TemplateResponse("partials/slm_device_list.html", {
"request": request, "request": request,
@@ -171,18 +157,25 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
is_measuring = False is_measuring = False
try: try:
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor async with httpx.AsyncClient(timeout=10.0) as client:
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we # Get measurement state
# no longer fire Measure? + a fresh DOD read at the device on every command- state_response = await client.get(
# center load (which competed with DOD polling for the single connection). f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
async with httpx.AsyncClient(timeout=5.0) as client: )
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status") if state_response.status_code == 200:
if r.status_code == 200: state_data = state_response.json()
current_status = r.json().get("data", {}) measurement_state = state_data.get("measurement_state", "Unknown")
measurement_state = current_status.get("measurement_state") is_measuring = state_data.get("is_measuring", False)
is_measuring = measurement_state in ("Start", "Measure")
# Get live status (measurement_start_time is already stored in SLMM database)
status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
)
if status_response.status_code == 200:
status_data = status_response.json()
current_status = status_data.get("data", {})
except Exception as e: except Exception as e:
logger.error(f"Failed to get cached status for {unit_id}: {e}") logger.error(f"Failed to get status for {unit_id}: {e}")
return templates.TemplateResponse("partials/slm_live_view.html", { return templates.TemplateResponse("partials/slm_live_view.html", {
"request": request, "request": request,
+2 -4
View File
@@ -14,7 +14,6 @@ import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,14 +58,13 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
except Exception as e: except Exception as e:
logger.warning(f"Failed to get SLM status for {unit_id}: {e}") logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
loc = get_active_location(db, unit_id)
return { return {
"unit_id": unit_id, "unit_id": unit_id,
"device_type": "slm", "device_type": "slm",
"deployed": unit.deployed, "deployed": unit.deployed,
"model": unit.slm_model or "NL-43", "model": unit.slm_model or "NL-43",
"location": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": unit.address or unit.location,
"coordinates": (loc or {}).get("coordinates") or "", "coordinates": unit.coordinates,
"note": unit.note, "note": unit.note,
"status": status_data, "status": status_data,
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None, "last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
-70
View File
@@ -231,76 +231,6 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
logger.info(f"WebSocket proxy closed for {unit_id} (live)") logger.info(f"WebSocket proxy closed for {unit_id} (live)")
@router.websocket("/{unit_id}/monitor")
async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str):
"""
Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed).
This is the shared ~1Hz DOD feed: many clients subscribe to one device feed
(no single-connection contention) and it carries L1/L10 (which the DRD
/stream cannot). Preferred over /stream for the live view.
"""
await websocket.accept()
logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)")
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
backend_ws = None
try:
backend_ws = await websockets.connect(target_ws_url)
logger.info(f"Connected to SLMM monitor feed for {unit_id}")
async def forward_to_client():
"""Backend monitor frames -> browser."""
async for message in backend_ws:
await websocket.send_text(message)
async def watch_client():
"""Drain client frames; raises WebSocketDisconnect on close so we can
tear the pair down (the monitor feed is server->client only)."""
while True:
await websocket.receive_text()
# When EITHER side ends (browser disconnects or backend closes), cancel the
# other immediately — avoids sending into a closed socket (the
# "Unexpected ASGI message after close" race that asyncio.gather leaves open).
tasks = [asyncio.ensure_future(forward_to_client()),
asyncio.ensure_future(watch_client())]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
# Await ALL tasks (the done one AND the cancelled one) and swallow both
# the expected WebSocketDisconnect and CancelledError. CancelledError is a
# BaseException, so a bare `except Exception` misses it — that's what leaked
# the traceback on stop; and awaiting only `pending` left the done task's
# exception unretrieved.
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except websockets.exceptions.WebSocketException as e:
logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}")
try:
await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)})
except Exception:
pass
except Exception as e:
logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}")
finally:
if backend_ws:
try:
await backend_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"WebSocket monitor proxy closed for {unit_id}")
# HTTP catch-all route MUST come after specific routes (including WebSocket routes) # HTTP catch-all route MUST come after specific routes (including WebSocket routes)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_slmm(path: str, request: Request): async def proxy_to_slmm(path: str, request: Request):
+16 -12
View File
@@ -5,7 +5,6 @@ from typing import Dict, Any, Optional
from backend.database import get_db from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.services.unit_location import get_active_location
from backend.models import RosterUnit from backend.models import RosterUnit
router = APIRouter(prefix="/api", tags=["units"]) router = APIRouter(prefix="/api", tags=["units"])
@@ -14,8 +13,7 @@ router = APIRouter(prefix="/api", tags=["units"])
@router.get("/unit/{unit_id}") @router.get("/unit/{unit_id}")
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
""" """
Returns detailed data for a single unit, including its active deployment Returns detailed data for a single unit.
location (or None if benched / unassigned).
""" """
snapshot = emit_status_snapshot() snapshot = emit_status_snapshot()
@@ -23,7 +21,17 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
unit_data = snapshot["units"][unit_id] unit_data = snapshot["units"][unit_id]
active_loc = get_active_location(db, unit_id)
# Mock coordinates for now (will be replaced with real data)
mock_coords = {
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
}
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
return { return {
"id": unit_id, "id": unit_id,
@@ -33,7 +41,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
"last_file": unit_data.get("fname", ""), "last_file": unit_data.get("fname", ""),
"deployed": unit_data["deployed"], "deployed": unit_data["deployed"],
"note": unit_data.get("note", ""), "note": unit_data.get("note", ""),
"active_location": active_loc, "coordinates": coords
} }
@@ -41,16 +49,12 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)): def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
""" """
Get unit data directly from the roster (for settings/configuration). Get unit data directly from the roster (for settings/configuration).
Address/coordinates come from the active MonitoringLocation, not the
roster row.
""" """
unit = db.query(RosterUnit).filter_by(id=unit_id).first() unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit: if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
active_loc = get_active_location(db, unit_id)
return { return {
"id": unit.id, "id": unit.id,
"unit_type": unit.unit_type, "unit_type": unit.unit_type,
@@ -58,9 +62,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"deployed": unit.deployed, "deployed": unit.deployed,
"retired": unit.retired, "retired": unit.retired,
"note": unit.note, "note": unit.note,
"active_location": active_loc, "location": unit.location,
"address": (active_loc or {}).get("address") or "", "address": unit.address,
"coordinates": (active_loc or {}).get("coordinates") or "", "coordinates": unit.coordinates,
"slm_host": unit.slm_host, "slm_host": unit.slm_host,
"slm_tcp_port": unit.slm_tcp_port, "slm_tcp_port": unit.slm_tcp_port,
"slm_ftp_port": unit.slm_ftp_port, "slm_ftp_port": unit.slm_ftp_port,
-295
View File
@@ -1,295 +0,0 @@
"""
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
+6 -21
View File
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
from backend.database import get_db_session from backend.database import get_db_session
from backend.models import Emitter, RosterUnit, IgnoredUnit from backend.models import Emitter, RosterUnit, IgnoredUnit
from backend.services.unit_location import bulk_active_locations
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -138,10 +137,6 @@ def emit_status_snapshot():
emitters = {e.id: e for e in db.query(Emitter).all()} emitters = {e.id: e for e in db.query(Emitter).all()}
ignored = {i.id for i in db.query(IgnoredUnit).all()} ignored = {i.id for i in db.query(IgnoredUnit).all()}
# Active-assignment location lookup for all roster units (direct only;
# modems inherit from their paired device below in the derive loop).
active_locs = bulk_active_locations(db, list(roster.values()))
# SFM event-forwards are now the primary "last seen" signal for # SFM event-forwards are now the primary "last seen" signal for
# seismographs. Watcher heartbeats stay as a backup — if SFM is down # seismographs. Watcher heartbeats stay as a backup — if SFM is down
# or hasn't seen a serial, we fall back to Emitter.last_seen. # or hasn't seen a serial, we fall back to Emitter.last_seen.
@@ -230,13 +225,10 @@ def emit_status_snapshot():
"ip_address": r.ip_address, "ip_address": r.ip_address,
"phone_number": r.phone_number, "phone_number": r.phone_number,
"hardware_model": r.hardware_model, "hardware_model": r.hardware_model,
# Location for mapping — sourced from active UnitAssignment # Location for mapping
# → MonitoringLocation. Empty for benched / unassigned. "location": r.location or "",
"address": (active_locs.get(unit_id) or {}).get("address") or "", "address": r.address or "",
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "", "coordinates": r.coordinates or "",
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
} }
# --- Add unexpected emitter-only units --- # --- Add unexpected emitter-only units ---
@@ -275,12 +267,10 @@ def emit_status_snapshot():
"ip_address": None, "ip_address": None,
"phone_number": None, "phone_number": None,
"hardware_model": None, "hardware_model": None,
# Location fields — unknown units have no assignment # Location fields
"location": "",
"address": "", "address": "",
"coordinates": "", "coordinates": "",
"location_name": "",
"project_id": "",
"location_id": "",
} }
# --- Derive modem status from paired devices --- # --- Derive modem status from paired devices ---
@@ -311,11 +301,6 @@ def emit_status_snapshot():
unit_data["last"] = paired_unit.get("last") unit_data["last"] = paired_unit.get("last")
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none") unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
unit_data["derived_from"] = paired_unit_id unit_data["derived_from"] = paired_unit_id
# Inherit deployment location too — modems don't carry
# their own UnitAssignment.
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
if not unit_data.get(k):
unit_data[k] = paired_unit.get(k, "")
# Separate buckets for UI # Separate buckets for UI
active_units = { active_units = {
-125
View File
@@ -1,125 +0,0 @@
"""
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
+10 -474
View File
@@ -28,27 +28,6 @@
(function () { (function () {
const MODAL_ID = 'event-detail-modal'; const MODAL_ID = 'event-detail-modal';
// ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ──
const _CHANNEL_COLORS = {
MicL: '#e066ff', // purple — distinct from the geo channels
Long: '#3b82f6', // blue
Vert: '#22c55e', // green
Tran: '#ef4444', // red
};
const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
// dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit).
const DBL_REF = 2.9e-9;
// Mic display floor — sound-pressure AC samples sit at the digitisation
// noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without
// a floor, the chart looks like a sparse pattern of "moments when sound
// briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve.
const MIC_DBL_FLOOR = 60;
let _charts = {}; // ch → Chart instance
let _micUnitPref = 'psi'; // refreshed via fetch on first chart render
let _micUnitPrefLoaded = false; // one-shot fetch guard
function _esc(s) { function _esc(s) {
if (s == null) return ''; if (s == null) return '';
return String(s).replace(/&/g, '&amp;') return String(s).replace(/&/g, '&amp;')
@@ -245,370 +224,32 @@
</div>`; </div>`;
} }
function _renderReview(s, eventId) {
const rev = s.review || {};
const ft = !!rev.false_trigger;
const reviewer = rev.reviewer || '';
const notes = rev.notes || '';
const reviewedAt = rev.reviewed_at
? rev.reviewed_at.replace('T', ' ').slice(0, 19)
: null;
return `<div class="bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" id="event-review-ft" ${ft ? 'checked' : ''}
class="w-4 h-4 rounded text-seismo-orange focus:ring-seismo-orange">
<span class="font-medium">Flag as false trigger</span>
</label>
<div class="flex items-center gap-2 text-sm flex-1 min-w-[180px]">
<label for="event-review-reviewer" class="text-gray-500">Reviewer</label>
<input type="text" id="event-review-reviewer" value="${_esc(reviewer)}"
placeholder="Initials or name"
class="flex-1 px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
<div class="mt-3">
<label for="event-review-notes" class="block text-xs text-gray-500 mb-1">Notes</label>
<textarea id="event-review-notes" rows="2"
placeholder="Optional context — what caused the FT, follow-up actions, etc."
class="w-full px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">${_esc(notes)}</textarea>
</div>
<div class="flex items-center justify-between gap-3 mt-3">
<span id="event-review-status" class="text-xs text-gray-500 dark:text-gray-400">
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
</span>
<button type="button"
onclick="window.saveEventReview('${_esc(eventId)}')"
class="px-4 py-1.5 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
Save
</button>
</div>
</div>`;
}
// ── Waveform / histogram chart helpers ──────────────────────────
async function _loadMicUnitPref() {
if (_micUnitPrefLoaded) return _micUnitPref;
try {
const r = await fetch('/api/settings/preferences');
if (r.ok) {
const prefs = await r.json();
_micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi';
}
} catch (e) {
// Network error → silent fall back to default 'psi'.
}
_micUnitPrefLoaded = true;
return _micUnitPref;
}
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Rectifying psi→dBL converter for per-sample values — see comments in
// sfm_webapp.html:2592-2607 for the floor rationale.
function _psiToDblForChart(psi) {
if (psi == null) return MIC_DBL_FLOOR;
const a = Math.abs(psi);
if (a === 0) return MIC_DBL_FLOOR;
const dbl = 20 * Math.log10(a / DBL_REF);
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
}
// Adaptive decimal formatter — sensible precision in the normal range,
// scientific notation only at the extremes.
function _fmtPeak(v, unit) {
if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
if (v === 0) return '0' + (unit ? ' ' + unit : '');
const a = Math.abs(v);
const u = unit ? ' ' + unit : '';
if (a >= 0.0001 && a < 10000) {
const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
return v.toFixed(d) + u;
}
return v.toExponential(2) + u;
}
function _destroyCharts() {
Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
_charts = {};
}
// Returns true when Tailwind dark mode is active (the `dark` class is
// toggled on <html> by Terra-View's theme handler). Drives chart grid
// + tick colors so they have contrast on both backgrounds.
function _isDark() {
return document.documentElement.classList.contains('dark');
}
function _renderWaveformInto(containerId, data, micUnit) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
_destroyCharts();
const channels = data.channels || {};
const ta = data.time_axis || {};
const sr = ta.sample_rate || 1024;
const dtMs = ta.dt_ms || (1000.0 / sr);
const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
const withData = _CHANNEL_ORDER.filter(ch =>
channels[ch] && (channels[ch].values || []).length > 0
);
const lastCh = withData[withData.length - 1];
// Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
// light is white. Grids + ticks need contrast on both.
const dark = _isDark();
const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const tickColor = dark ? '#94a3b8' : '#64748b';
if (withData.length === 0) {
container.innerHTML = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
No waveform samples decoded codec walker returned 0 valid blocks for this event.
</div>`;
return;
}
for (const ch of _CHANNEL_ORDER) {
const chData = channels[ch];
if (!chData) continue;
let values = chData.values || [];
let chUnit = chData.unit || '';
let chPeak = chData.peak;
// Mic: convert psi → dBL when the user pref is dBL (default).
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDblForChart);
chPeak = _psiToDbl(chPeak);
chUnit = 'dB(L)';
}
const wrap = document.createElement('div');
wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
const lbl = document.createElement('div');
lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
lbl.style.color = _CHANNEL_COLORS[ch];
const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
lbl.innerHTML = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
wrap.appendChild(lbl);
if (values.length === 0) {
const e = document.createElement('div');
e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
e.textContent = 'no samples decoded';
wrap.appendChild(e);
container.appendChild(wrap);
continue;
}
const canvasWrap = document.createElement('div');
canvasWrap.className = 'relative';
canvasWrap.style.height = '100px';
const canvas = document.createElement('canvas');
canvasWrap.appendChild(canvas);
wrap.appendChild(canvasWrap);
container.appendChild(wrap);
// X-axis: waveforms use ms-relative-to-trigger; histograms use
// the BW-reported interval timestamps (HH:MM:SS) when the server
// aggregated to BW intervals, else interval index.
let times;
if (isHistogram) {
const intervalTimes = ta.interval_times || [];
times = (intervalTimes.length === values.length)
? intervalTimes
: values.map((_, i) => i + 1);
} else {
times = values.map((_, i) => t0Ms + i * dtMs);
}
// Downsample for rendering when very long.
const MAX = 3000;
let rT = times, rV = values;
if (values.length > MAX) {
const step = Math.ceil(values.length / MAX);
rT = times.filter((_, i) => i % step === 0);
rV = values.filter((_, i) => i % step === 0);
}
const showX = (ch === lastCh);
const xAxisLabel = isHistogram ? '' : ' ms';
const fmtTick = i => {
const v = rT[i];
if (typeof v === 'number') {
const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
return s + xAxisLabel;
}
return String(v) + xAxisLabel;
};
// Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
let yBounds = {};
const isGeo = ch !== 'MicL';
if (isGeo && !isHistogram) {
let absMax = 0;
for (const v of values) {
const a = Math.abs(v);
if (a > absMax) absMax = a;
}
const padded = (absMax || 1) * 1.10;
yBounds = { min: -padded, max: padded };
} else if (isGeo && isHistogram) {
const HIST_GEO_MIN_INS = 0.05;
let peak = 0;
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
} else if (ch === 'MicL' && micUnit === 'dBL') {
const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
? chPeak + 5 : 100;
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
} else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
const HIST_MIC_MIN_PSI = 0.001;
let peak = 0;
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
}
_charts[ch] = new Chart(canvas, {
type: isHistogram ? 'bar' : 'line',
data: {
labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
datasets: isHistogram ? [{
data: rV,
backgroundColor: _CHANNEL_COLORS[ch],
borderWidth: 0,
barPercentage: 1.0,
categoryPercentage: 1.0,
}] : [{
data: rV,
borderColor: _CHANNEL_COLORS[ch],
borderWidth: 1,
pointRadius: 0,
tension: 0,
}],
},
options: {
animation: false, responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index', intersect: false,
callbacks: {
title: items => isHistogram
? `interval ${items[0].label}`
: `t = ${items[0].label} ms`,
label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
},
},
},
scales: {
x: {
type: 'category', display: showX,
ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
grid: { color: gridColor, drawTicks: showX },
},
y: {
...yBounds,
ticks: { color: tickColor, maxTicksLimit: 4 },
grid: { color: gridColor },
title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
},
},
},
plugins: isHistogram ? [] : [{
id: 'overlays',
afterDraw(chart) {
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
const zi = rT.findIndex(t => parseFloat(t) >= 0);
if (zi >= 0) {
const px = x.getPixelForValue(zi);
ctx.save();
ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
ctx.save();
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
ctx.closePath(); ctx.fill();
ctx.restore();
}
const zy = y.getPixelForValue(0);
if (zy >= y.top && zy <= y.bottom) {
ctx.save();
ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
ctx.setLineDash([2, 2]);
ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
ctx.restore();
ctx.save();
ctx.fillStyle = tickColor; ctx.font = '10px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('0.0', x.right + 6, zy);
ctx.restore();
}
},
}],
});
}
}
function _renderFileInfo(s, eventId) { function _renderFileInfo(s, eventId) {
const bw = s.blastware || {}; const bw = s.blastware || {};
const src = s.source || {}; const src = s.source || {};
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null; const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
const canDownloadBinary = !!(bw.available && bw.filename && eventId); const canDownloadBinary = !!(bw.available && bw.filename && eventId);
const txtFilename = src && src.txt_filename;
const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`;
const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`;
const downloadButtons = ` const downloadButtons = `
<div class="flex flex-wrap gap-2 mb-4"> <div class="flex flex-wrap gap-2 mb-4">
<button type="button"
onclick="window.toggleEventPdfPreview()"
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span id="event-pdf-toggle-label">Show Event Report PDF</span>
</button>
<a href="${reportPdfUrl}" download
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download PDF
</a>
${canDownloadBinary ? ` ${canDownloadBinary ? `
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file" <a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
download="${_esc(bw.filename)}" download="${_esc(bw.filename)}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors"> class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>
Blastware binary Download Blastware file
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span> <span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
</a> </a>
` : ''} ` : `
${txtFilename ? ` <span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>
Original .TXT report Blastware file unavailable
</a> </span>
` : ''} `}
<button type="button" <button type="button"
onclick="window.toggleEventJsonViewer()" onclick="window.toggleEventJsonViewer()"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors"> class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
@@ -626,11 +267,6 @@
Download sidecar JSON Download sidecar JSON
</a> </a>
</div> </div>
<div id="event-pdf-preview" class="hidden mb-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-slate-900">
<iframe id="event-pdf-iframe" title="Event Report PDF preview"
class="w-full" style="height:80vh; min-height:600px; border:0;"
data-pdf-url="${reportPdfUrl}"></iframe>
</div>
<div id="event-json-viewer" class="hidden mb-4"> <div id="event-json-viewer" class="hidden mb-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span> <span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
@@ -647,9 +283,7 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div> <div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '')}</span></div> <div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '')}</span></div>
<div title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that)."> <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>
<span class="text-gray-500">Time received</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '')}</span>
</div>
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '')}</span></div> <div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '')}</span></div>
</div>`; </div>`;
} }
@@ -711,10 +345,6 @@
${_sectionHeader('Peak Particle Velocity')} ${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)} ${_renderPeakValues(s)}
${_sectionHeader('Waveform')}
<div id="event-waveform-status" class="text-xs text-gray-500 dark:text-gray-400 italic mb-2">Loading waveform</div>
<div id="event-waveform-charts" class="space-y-0.5"></div>
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? ` ${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
${_sectionHeader('Microphone')} ${_sectionHeader('Microphone')}
${_renderMic(s)} ${_renderMic(s)}
@@ -728,43 +358,14 @@
${_renderDeviceMetadata(s)} ${_renderDeviceMetadata(s)}
` : ''} ` : ''}
${_sectionHeader('Review')}
${_renderReview(s, eventId)}
${_sectionHeader('Source File')} ${_sectionHeader('Source File')}
${_renderFileInfo(s, eventId)} ${_renderFileInfo(s, eventId)}
`; `;
// Waveform load runs after the sidecar content is in the DOM, in
// parallel with the mic-unit-pref fetch. Either may complete first.
try {
const [wfRes, micUnit] = await Promise.all([
fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`),
_loadMicUnitPref(),
]);
if (wfRes.status === 404) {
document.getElementById('event-waveform-status').textContent =
'No waveform data — codec returned 0 valid blocks for this event.';
return;
}
if (!wfRes.ok) {
document.getElementById('event-waveform-status').textContent =
'Failed to load waveform: HTTP ' + wfRes.status;
return;
}
const wfData = await wfRes.json();
document.getElementById('event-waveform-status').textContent = '';
_renderWaveformInto('event-waveform-charts', wfData, micUnit);
} catch (e) {
const st = document.getElementById('event-waveform-status');
if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message);
}
}; };
window.closeEventDetailModal = function () { window.closeEventDetailModal = function () {
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
if (modal) modal.classList.add('hidden'); if (modal) modal.classList.add('hidden');
_destroyCharts();
}; };
window.toggleEventJsonViewer = function () { window.toggleEventJsonViewer = function () {
@@ -775,71 +376,6 @@
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON'; if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
}; };
window.toggleEventPdfPreview = function () {
const preview = document.getElementById('event-pdf-preview');
const iframe = document.getElementById('event-pdf-iframe');
const label = document.getElementById('event-pdf-toggle-label');
if (!preview || !iframe) return;
const isHidden = preview.classList.toggle('hidden');
// Lazy-load the PDF: only set the iframe src on first reveal, so
// closing the event modal without opening the PDF never spends
// bandwidth on it.
if (!isHidden && !iframe.src) {
iframe.src = iframe.dataset.pdfUrl || '';
}
if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF';
// Scroll the iframe into view on first reveal so the operator
// doesn't have to hunt for it after clicking.
if (!isHidden) {
preview.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
window.saveEventReview = async function (eventId) {
const ft = document.getElementById('event-review-ft');
const reviewer = document.getElementById('event-review-reviewer');
const notes = document.getElementById('event-review-notes');
const status = document.getElementById('event-review-status');
if (!ft || !reviewer || !notes) return;
const payload = {
review: {
false_trigger: ft.checked,
reviewer: reviewer.value.trim() || null,
notes: notes.value.trim() || null,
}
};
if (status) {
status.textContent = 'Saving…';
status.className = 'text-xs text-gray-500 dark:text-gray-400';
}
try {
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) {
const t = await r.text().catch(() => '');
throw new Error('HTTP ' + r.status + (t ? `${t.slice(0, 120)}` : ''));
}
if (status) {
status.textContent = 'Saved.';
status.className = 'text-xs text-green-600 dark:text-green-400';
}
// Notify the host page so its event-list FT badge / row state
// can refresh. Pages opt in by listening for this event.
window.dispatchEvent(new CustomEvent('sfm-event-review-saved', {
detail: { eventId, review: payload.review },
}));
} catch (e) {
if (status) {
status.textContent = 'Save failed: ' + e.message;
status.className = 'text-xs text-red-600 dark:text-red-400';
}
}
};
window.copyEventJson = function () { window.copyEventJson = function () {
const pre = document.getElementById('event-json-pre'); const pre = document.getElementById('event-json-pre');
const label = document.getElementById('event-json-copy-label'); const label = document.getElementById('event-json-copy-label');
+2 -11
View File
@@ -1,27 +1,18 @@
/* Service Worker for Seismo Fleet Manager PWA */ /* Service Worker for Seismo Fleet Manager PWA */
/* Network-first strategy with cache fallback for real-time data */ /* Network-first strategy with cache fallback for real-time data */
// IMPORTANT: bump this on every release that touches a precached or const CACHE_VERSION = 'v1';
// runtime-cached static asset (event-modal.js, mobile.js, style.css,
// templates served at /, etc.). The activate handler deletes any cache
// not matching CACHE_VERSION, so old SW caches get evicted and mobile
// PWA users actually receive the new bundles instead of being stuck on
// the pre-bump version. Convention: keep it in sync with the Terra-View
// version string in backend/main.py.
const CACHE_VERSION = 'v0.13.2';
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
// Files to precache (critical app shell). event-modal.js is included // Files to precache (critical app shell)
// so its cache lifecycle is tied to the SW version bump explicitly.
const STATIC_FILES = [ const STATIC_FILES = [
'/', '/',
'/static/style.css', '/static/style.css',
'/static/mobile.css', '/static/mobile.css',
'/static/mobile.js', '/static/mobile.js',
'/static/offline-db.js', '/static/offline-db.js',
'/static/event-modal.js',
'/static/manifest.json', '/static/manifest.json',
'https://cdn.tailwindcss.com', 'https://cdn.tailwindcss.com',
'https://unpkg.com/htmx.org@1.9.10', 'https://unpkg.com/htmx.org@1.9.10',
+1 -14
View File
@@ -1,6 +1,6 @@
services: services:
web-app: terra-view:
build: . build: .
ports: ports:
- "8001:8001" - "8001:8001"
@@ -11,10 +11,6 @@ services:
- ENVIRONMENT=production - ENVIRONMENT=production
- SLMM_BASE_URL=http://host.docker.internal:8100 - SLMM_BASE_URL=http://host.docker.internal:8100
- SFM_BASE_URL=http://sfm:8200 - SFM_BASE_URL=http://sfm:8200
# Display timezone for server logs + any text-rendered timestamps.
# DB columns are stored UTC regardless; this only affects what
# operators see. Override here for non-US-East deployments.
- TZ=America/New_York
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- slmm - slmm
@@ -60,18 +56,9 @@ services:
volumes: volumes:
- ../seismo-relay/sfm/data:/app/sfm/data - ../seismo-relay/sfm/data:/app/sfm/data
- ../seismo-relay/bridges/captures:/app/bridges/captures - ../seismo-relay/bridges/captures:/app/bridges/captures
# The DB + waveform store inside bridges/captures are symlinks
# pointing at the prod-snap directory. Mount its host path at
# the same absolute path inside the container so the symlinks
# resolve. Needed for SFM to query the events DB.
- ../seismo-relay-prod-snap:/home/serversdown/seismo-relay-prod-snap
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PORT=8200 - PORT=8200
# Display timezone — affects server log timestamps, the PDF
# report renderer's UTC→local conversions, and matplotlib's
# datetime axes. DB columns (created_at etc.) are always UTC.
- TZ=America/New_York
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8200/health"] test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
-208
View File
@@ -1,208 +0,0 @@
# Client Portal — Design & Build Plan
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
A client-facing, **read-only**, **scoped** view into a client's own monitoring
data. The first internet-facing-with-real-clients surface in the system. Built
*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM
reads and Terra-View's report generation — Terra-View stays the UI/business layer;
SLMM stays the device layer.
## Principles
1. **Read-only.** No device control (start/stop/config), no roster editing, no
internal pages. A client can look, never touch.
2. **Strictly scoped.** A client only ever sees data for *their* projects. Every
portal endpoint verifies ownership server-side — never trust a `unit_id` /
`location_id` from the request.
3. **Cache-first, no device contention.** Portal live data comes from SLMM's
cache (the same cached `/status` + `/history` the internal dashboard uses).
No device-hitting calls from the portal — a client can't make us hammer the
NL-43. Freshness depends on **keepalive being on** for the client's units.
4. **Auth is a swappable gate.** Every route depends on one resolver,
`get_current_client()`. M1M3 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
# M1M3: read signed `portal_client` cookie -> load Client
# M4: same signature, backed by real sessions (magic-link / password)
```
**Interim "magic URL" flow (M1M3):**
- 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.
-67
View File
@@ -1,67 +0,0 @@
# 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`.
-1
View File
@@ -9,4 +9,3 @@ Pillow==10.1.0
httpx==0.25.2 httpx==0.25.2
openpyxl==3.1.2 openpyxl==3.1.2
rapidfuzz==3.10.1 rapidfuzz==3.10.1
schedule==1.2.2
+2 -13
View File
@@ -192,9 +192,8 @@ function renderTable() {
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>' ? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
: ''; : '';
const checked = _selected.has(ev.id) ? 'checked' : ''; const checked = _selected.has(ev.id) ? 'checked' : '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
onclick="showEventDetail('${_esc(ev.id)}')"> <td class="px-3 py-2"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
<td class="px-3 py-2" onclick="event.stopPropagation()"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td> <td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td> <td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td> <td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
@@ -356,15 +355,5 @@ async function flagSelected(value) {
} }
// Initial empty state — let the user choose to load. // Initial empty state — let the user choose to load.
// Refresh the events table when the modal's review form saves — keeps
// the FT badge in sync without a full page reload.
window.addEventListener('sfm-event-review-saved', () => {
if (_events.length) loadEvents();
});
</script> </script>
{# Shared event-detail modal — rendered by /static/event-modal.js #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
{% endblock %} {% endblock %}
+1 -66
View File
@@ -42,18 +42,6 @@
</div> </div>
</div> </div>
<!-- Live Monitoring (keepalive) -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-1">Live Monitoring (keepalive)</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Keepalive runs the 1&nbsp;Hz DOD feed 24/7 (even with no viewer), which powers the live-chart
trail and continuous threshold alerts. Toggling persists and survives restarts.
</p>
<div id="monitor-list" class="text-sm">
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
</div>
</div>
<!-- Raw API tester --> <!-- Raw API tester -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6"> <div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2> <h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
@@ -144,60 +132,7 @@ async function sendRaw() {
} }
} }
async function loadMonitors() {
const el = document.getElementById('monitor-list');
try {
const r = await fetch('/api/slmm/roster');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const devices = d.devices || [];
if (!devices.length) {
el.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No devices configured.</p>';
return;
}
el.innerHTML = devices.map(dev => {
const on = !!dev.monitor_enabled;
const reach = dev.status ? dev.status.is_reachable : null;
const reachDot = reach === false
? '<span class="w-2 h-2 rounded-full bg-red-500 inline-block" title="unreachable"></span>'
: '<span class="w-2 h-2 rounded-full bg-green-500 inline-block" title="reachable"></span>';
return `
<div class="flex items-center justify-between border-b border-gray-100 dark:border-gray-700 py-2">
<div class="flex items-center gap-2">
${reachDot}
<span class="font-mono text-gray-900 dark:text-white">${_esc(dev.unit_id)}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(dev.host)}:${_esc(dev.tcp_port)}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs font-medium px-2 py-0.5 rounded ${on
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400'}">${on ? '24/7 ON' : 'OFF'}</span>
<button onclick="toggleMonitor('${_esc(dev.unit_id)}', ${!on})"
class="px-3 py-1 text-xs rounded text-white ${on
? 'bg-red-600 hover:bg-red-700' : 'bg-seismo-orange hover:bg-orange-600'}">
${on ? 'Disable' : 'Enable'}
</button>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = `<p class="text-red-600 dark:text-red-400">Failed to load devices: ${_esc(e.message)}</p>`;
}
}
async function toggleMonitor(unitId, enable) {
const action = enable ? 'start' : 'stop';
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/monitor/${action}`, { method: 'POST' });
if (!r.ok) throw new Error('HTTP ' + r.status);
await loadMonitors();
} catch (e) {
alert('Toggle failed: ' + e.message);
}
}
loadSlmmOverview(); loadSlmmOverview();
loadMonitors(); setInterval(loadSlmmOverview, 30000);
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
</script> </script>
{% endblock %} {% endblock %}
+51 -86
View File
@@ -150,55 +150,46 @@ setInterval(_refreshPendingDeployBanner, 30000);
</svg> </svg>
</div> </div>
</div> </div>
<div class="space-y-4 card-content" id="fleet-summary-content"> <div class="space-y-3 card-content" id="fleet-summary-content">
<!-- Seismographs --> <div class="flex justify-between items-center">
<div> <span class="text-gray-600 dark:text-gray-400">Total Units</span>
<div class="flex justify-between items-center mb-1.5"> <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="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg> </svg>
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a> <a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
</div> </div>
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span> <span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
</div> </div>
<div class="pl-6 flex flex-col gap-0.5 text-sm"> <div class="flex justify-between items-center mb-2">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
</div>
</div>
<!-- Sound Level Meters -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg> </svg>
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a> <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>
</div>
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
</div>
<div class="pl-6 flex flex-col gap-0.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div> </div>
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
</div> </div>
</div> </div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"> <div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p> <p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)"> <div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
<div class="flex items-center"> <div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center"> <span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
@@ -637,14 +628,9 @@ function updateFleetMapFiltered(allUnits) {
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker)); fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = []; fleetMarkers = [];
// Get deployed units with coordinates that pass the filter. // Get deployed units with coordinates that pass the filter
// Modems are not plotted — they inherit the paired device's location,
// which would just stack a duplicate marker on the same pin.
const deployedUnits = Object.entries(allUnits || {}) const deployedUnits = Object.entries(allUnits || {})
.filter(([_, u]) => u.deployed .filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
&& u.coordinates
&& (u.device_type || 'seismograph') !== 'modem'
&& unitPassesFilter(u));
if (deployedUnits.length === 0) { if (deployedUnits.length === 0) {
return; return;
@@ -686,12 +672,10 @@ function updateFleetMapFiltered(allUnits) {
// Popup with device type // Popup with device type
const deviceLabel = getDeviceTypeLabel(deviceType); const deviceLabel = getDeviceTypeLabel(deviceType);
const locName = unit.location_name || '';
marker.bindPopup(` marker.bindPopup(`
<div class="p-2"> <div class="p-2">
<h3 class="font-bold text-lg">${id}</h3> <h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm text-gray-600">${deviceLabel}</p> <p class="text-sm text-gray-600">${deviceLabel}</p>
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p> <p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''} ${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a> <a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
@@ -799,51 +783,32 @@ function updateDashboard(event) {
timeZoneName: 'short' timeZoneName: 'short'
}); });
// ===== Fleet Summary: per-device-type counts (always unfiltered) ===== // ===== Fleet summary numbers (always unfiltered) =====
// Deployed = unit has an active UnitAssignment (location_id set by document.getElementById('total-units').textContent = data.summary?.total ?? 0;
// the snapshot helper). Benched = no active assignment. document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
// Retired, out-for-calibration, and roster-unknown units (emitters document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
// not in the roster) are excluded from totals. document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
const counts = { document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
seismograph: { total: 0, deployed: 0, benched: 0 }, document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
sound_level_meter: { total: 0, deployed: 0, benched: 0 }, document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
};
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
const unknownIds = new Set(Object.keys(data.unknown || {}));
Object.entries(data.units || {}).forEach(([uid, unit]) => { // ===== Device type counts (always unfiltered) =====
if (unit.retired || unit.out_for_calibration) return; let seismoCount = 0;
if (unknownIds.has(uid)) return; let slmCount = 0;
const dt = unit.device_type || 'seismograph'; let modemCount = 0;
const bucket = counts[dt]; Object.values(data.units || {}).forEach(unit => {
if (!bucket) return; // skip modems and anything else if (unit.retired) return; // Don't count retired units
const deviceType = unit.device_type || 'seismograph';
bucket.total++; if (deviceType === 'seismograph') {
if (unit.location_id) { seismoCount++;
bucket.deployed++; } else if (deviceType === 'sound_level_meter') {
} else { slmCount++;
bucket.benched++; } else if (deviceType === 'modem') {
} modemCount++;
// Status tally only for seismographs + SLMs that are actually
// deployed (assigned). Mirrors the per-device buckets so the
// sum matches.
if (unit.location_id) {
if (unit.status === 'OK') monitoredOk++;
else if (unit.status === 'Pending') monitoredPending++;
else if (unit.status === 'Missing') monitoredMissing++;
} }
}); });
document.getElementById('seismo-count').textContent = seismoCount;
document.getElementById('seismo-count').textContent = counts.seismograph.total; document.getElementById('slm-count').textContent = slmCount;
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
document.getElementById('status-ok').textContent = monitoredOk;
document.getElementById('status-pending').textContent = monitoredPending;
document.getElementById('status-missing').textContent = monitoredMissing;
// ===== Apply filters and render map + alerts ===== // ===== Apply filters and render map + alerts =====
renderFilteredDashboard(data); renderFilteredDashboard(data);
+2 -6
View File
@@ -10,8 +10,8 @@ Usage:
#} #}
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden"> <div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div> <div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-5xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[92vh] overflow-y-auto"> <div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 z-10"> <div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3> <h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
<button onclick="closeEventDetailModal()" <button onclick="closeEventDetailModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"> class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
@@ -23,7 +23,3 @@ Usage:
<div id="event-detail-modal-content"></div> <div id="event-detail-modal-content"></div>
</div> </div>
</div> </div>
{# Chart.js — pinned to v4.4.1 to match the SFM webapp's reference impl
(v4 chart API; differs from v3). Loaded once globally; safe if other
pages on the same template tree also load it. #}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
+29 -39
View File
@@ -2,14 +2,7 @@
{% if units %} {% if units %}
{% for unit in units %} {% for unit in units %}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative"> <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
<div class="absolute top-3 right-3 flex gap-2 z-10"> <div class="absolute top-3 right-3 flex gap-2">
<button onclick="event.preventDefault(); event.stopPropagation(); refreshSlmUnit('{{ unit.id }}', this);"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="Refresh {{ unit.id }} from device">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');" <button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="View live chart"> title="View live chart">
@@ -27,44 +20,41 @@
</button> </button>
</div> </div>
<a href="/slm/{{ unit.id }}" class="block pr-24"> <a href="/slm/{{ unit.id }}" class="block">
<div class="min-w-0"> <div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-2"> <div class="min-w-0 flex-1">
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span> <div class="flex items-center gap-2">
{% if unit.slm_model %} <span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span> {% if unit.slm_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
{% endif %}
</div>
{% if unit.address %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
{% elif unit.location %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
{% endif %} {% endif %}
</div> </div>
{% if unit.address %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p> {% if unit.retired %}
{% elif unit.location %} <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>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p> {% elif not unit.deployed %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif unit.measurement_state == "Start" %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
{% elif unit.is_recent %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
{% else %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
{% endif %} {% endif %}
</div> </div>
<!-- Status badge + last-check on one line (moved off the top-right so it <div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
no longer collides with the refresh/chart/gear action icons). --> {% if unit.slm_last_check %}
<div class="mt-2 flex items-center gap-2 flex-wrap"> Last check: {{ unit.slm_last_check|local_datetime }}
{% if unit.retired %}
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
{% elif not unit.deployed %}
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif unit.measurement_state in ["Start", "Measure"] %}
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
{% elif unit.is_recent %}
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
{% else %} {% else %}
<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> No recent check-in
{% endif %} {% endif %}
<span class="text-xs text-gray-500 dark:text-gray-400">
{% if unit.cache_last_seen %}
Last check: {{ unit.cache_last_seen|local_datetime }}
{% elif unit.slm_last_check %}
Last check: {{ unit.slm_last_check|local_datetime }}
{% else %}
No recent check-in
{% endif %}
</span>
</div> </div>
</a> </a>
</div> </div>
+19 -113
View File
@@ -143,8 +143,6 @@
</svg> </svg>
Stop Live Stream Stop Live Stream
</button> </button>
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
</div> </div>
</div> </div>
@@ -175,17 +173,17 @@
</div> </div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4"> <div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<p 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 class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
<p id="live-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400"> <p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{% if current_status and current_status.ln1 %}{{ current_status.ln1 }}{% else %}--{% endif %} {% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4"> <div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<p 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 class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
<p id="live-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400"> <p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{% if current_status and current_status.ln2 %}{{ current_status.ln2 }}{% else %}--{% endif %} {% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
@@ -434,24 +432,6 @@ function initializeChart() {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0 pointRadius: 0
},
{
label: 'L1',
data: [],
borderColor: 'rgb(139, 92, 246)',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
},
{
label: 'L10',
data: [],
borderColor: 'rgb(245, 158, 11)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
} }
] ]
}, },
@@ -513,37 +493,7 @@ if (typeof window.currentWebSocket === 'undefined') {
window.currentWebSocket = null; window.currentWebSocket = null;
} }
// Backfill the chart with the recent DOD trail so it opens with context. function initLiveDataStream(unitId) {
async function backfillChart(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
if (!r.ok) return;
const d = await r.json();
const readings = d.readings || [];
if (!window.chartData) return;
for (const row of readings) {
// Trail timestamps are naive UTC; append 'Z' so they convert to local
// consistently with the live frames (which use local Date.now()).
window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
window.chartData.lp.push(parseFloat(row.lp || 0));
window.chartData.leq.push(parseFloat(row.leq || 0));
window.chartData.ln1.push(parseFloat(row.ln1 || 0));
window.chartData.ln2.push(parseFloat(row.ln2 || 0));
}
if (window.liveChart) {
window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none');
}
} catch (e) {
console.warn('Chart backfill failed:', e);
}
}
async function initLiveDataStream(unitId) {
// Close existing connection if any // Close existing connection if any
if (window.currentWebSocket) { if (window.currentWebSocket) {
window.currentWebSocket.close(); window.currentWebSocket.close();
@@ -554,24 +504,17 @@ async function initLiveDataStream(unitId) {
window.chartData.timestamps = []; window.chartData.timestamps = [];
window.chartData.lp = []; window.chartData.lp = [];
window.chartData.leq = []; window.chartData.leq = [];
window.chartData.ln1 = [];
window.chartData.ln2 = [];
} }
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) { if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
window.liveChart.data.labels = []; window.liveChart.data.labels = [];
window.liveChart.data.datasets.forEach(ds => ds.data = []); window.liveChart.data.datasets[0].data = [];
window.liveChart.data.datasets[1].data = [];
window.liveChart.update(); window.liveChart.update();
} }
// Seed the chart with recent history BEFORE opening the live socket, so live // WebSocket URL for SLMM backend via proxy
// frames append after the backfill (right order) and the chart isn't blank.
await backfillChart(unitId);
// WebSocket URL for SLMM backend via proxy.
// /monitor = the shared fan-out DOD feed (many viewers, one device connection,
// and it carries L1/L10 which the DRD /stream cannot).
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`; const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
window.currentWebSocket = new WebSocket(wsUrl); window.currentWebSocket = new WebSocket(wsUrl);
@@ -587,11 +530,7 @@ async function initLiveDataStream(unitId) {
window.currentWebSocket.onmessage = function(event) { window.currentWebSocket.onmessage = function(event) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a console.log('WebSocket data received:', data);
// 'feed_status' on each frame. Reflect status, but don't let a heartbeat
// or an 'unreachable' frame blank the cards / spike the chart with zeros.
updateFeedStatus(data.feed_status);
if (data.heartbeat || data.feed_status === 'unreachable') return;
updateLiveMetrics(data); updateLiveMetrics(data);
updateLiveChart(data); updateLiveChart(data);
} catch (error) { } catch (error) {
@@ -620,21 +559,6 @@ function stopLiveDataStream() {
} }
} }
// Reflect device reachability from the monitor feed's feed_status. Safe no-op
// if the badge element isn't on the page.
function updateFeedStatus(status) {
const el = document.getElementById('live-feed-status');
if (!el || status == null) return;
if (status === 'unreachable') {
el.textContent = 'Device offline';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
} else {
el.textContent = 'Live';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
el.style.display = '';
}
// Update metrics display // Update metrics display
function updateLiveMetrics(data) { function updateLiveMetrics(data) {
if (document.getElementById('live-lp')) { if (document.getElementById('live-lp')) {
@@ -646,20 +570,11 @@ function updateLiveMetrics(data) {
if (document.getElementById('live-lmax')) { if (document.getElementById('live-lmax')) {
document.getElementById('live-lmax').textContent = data.lmax || '--'; document.getElementById('live-lmax').textContent = data.lmax || '--';
} }
// Only update Ln values when the frame actually carries them. DRD stream if (document.getElementById('live-lmin')) {
// frames omit percentiles (DOD-only), so without this guard a live stream document.getElementById('live-lmin').textContent = data.lmin || '--';
// 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 (data.ln1_label && document.getElementById('live-ln1-label')) { if (document.getElementById('live-lpeak')) {
document.getElementById('live-ln1-label').textContent = data.ln1_label; document.getElementById('live-lpeak').textContent = data.lpeak || '--';
}
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;
} }
} }
@@ -668,9 +583,7 @@ if (typeof window.chartData === 'undefined') {
window.chartData = { window.chartData = {
timestamps: [], timestamps: [],
lp: [], lp: [],
leq: [], leq: []
ln1: [],
ln2: []
}; };
} }
@@ -680,17 +593,12 @@ function updateLiveChart(data) {
window.chartData.timestamps.push(now.toLocaleTimeString()); window.chartData.timestamps.push(now.toLocaleTimeString());
window.chartData.lp.push(parseFloat(data.lp || 0)); window.chartData.lp.push(parseFloat(data.lp || 0));
window.chartData.leq.push(parseFloat(data.leq || 0)); window.chartData.leq.push(parseFloat(data.leq || 0));
window.chartData.ln1.push(parseFloat(data.ln1 || 0));
window.chartData.ln2.push(parseFloat(data.ln2 || 0));
// Keep a rolling window large enough to hold the ~2h backfill (one point/min) // Keep only last 60 data points
// plus a good run of live points before the oldest scroll off. if (window.chartData.timestamps.length > 60) {
if (window.chartData.timestamps.length > 600) {
window.chartData.timestamps.shift(); window.chartData.timestamps.shift();
window.chartData.lp.shift(); window.chartData.lp.shift();
window.chartData.leq.shift(); window.chartData.leq.shift();
window.chartData.ln1.shift();
window.chartData.ln2.shift();
} }
// Update chart if available // Update chart if available
@@ -698,8 +606,6 @@ function updateLiveChart(data) {
window.liveChart.data.labels = window.chartData.timestamps; window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp; window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq; window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none'); window.liveChart.update('none');
} }
} }
+3 -5
View File
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
if (typeof checkFTPStatus === 'function') { if (typeof checkFTPStatus === 'function') {
checkFTPStatus(unitId); checkFTPStatus(unitId);
} }
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) { if (typeof htmx !== 'undefined') {
htmx.trigger('#slm-list', 'load'); htmx.trigger('#slm-list', 'load');
} }
}, 1500); }, 1500);
@@ -604,10 +604,8 @@ async function toggleSLMDeployed() {
successDiv.classList.remove('hidden'); successDiv.classList.remove('hidden');
setTimeout(() => successDiv.classList.add('hidden'), 3000); setTimeout(() => successDiv.classList.add('hidden'), 3000);
// Refresh any SLM list on the page (only if one is actually present — // Refresh any SLM list on the page
// the detail/dashboard pages have no #slm-list, and htmx.trigger on a if (typeof htmx !== 'undefined') {
// null target throws "can't access property dispatchEvent, e is null").
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
htmx.trigger('#slm-list', 'load'); htmx.trigger('#slm-list', 'load');
} }
} catch (error) { } catch (error) {
-19
View File
@@ -1,19 +0,0 @@
{% 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 %}
-196
View File
@@ -1,196 +0,0 @@
<!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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
-335
View File
@@ -1,335 +0,0 @@
{% 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">
&#9208; 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 %}
-192
View File
@@ -1,192 +0,0 @@
{% 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">&ndash;</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">&ndash;</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">&ndash;</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">&ndash;</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&nbsp;Leq</span>
</div>
<div class="loc-fresh text-[11px] text-[var(--text-dim)]/70 mt-2 font-mono">&nbsp;</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 += ` &middot; ${esc(st.leqStr)} dB Leq`;
else if (st.status === 'stopped') label += ' &middot; stopped';
else if (st.status === 'nodevice') label += ' &middot; no device';
else label += ' &middot; 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 = '&nbsp;';
} 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 %}
+1 -143
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
<div class="mb-6 flex items-center justify-between gap-3"> <div class="mb-6">
<nav class="flex items-center space-x-2 text-sm"> <nav class="flex items-center space-x-2 text-sm">
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center"> <a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -17,28 +17,6 @@
</svg> </svg>
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span> <span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
</nav> </nav>
<!-- Client portal actions for this project -->
<div class="shrink-0 flex items-center gap-2">
<button type="button" onclick="openShareModal()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
title="Get a shareable link to this project's client portal">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 11-5.656-5.656l1.5-1.5"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 115.656 5.656l-1.5 1.5"></path>
</svg>
Copy client link
</button>
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
title="Preview this project's client portal in a new tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
View client portal
</a>
</div>
</div> </div>
<!-- Header (loads dynamically) --> <!-- Header (loads dynamically) -->
@@ -2096,125 +2074,5 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
</script>
<!-- Share client portal link modal -->
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick="if(event.target===this)closeShareModal()">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
<div class="flex items-center justify-between mb-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal link</h3>
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Anyone with a link can view this project's client portal (read-only). Links are revocable.
</p>
{% if portal_open_links %}
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) -->
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label>
<div class="flex gap-2">
<input id="open-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
</div>
{% endif %}
<div id="share-new" class="hidden mb-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link &mdash; copy it now</label>
<div class="flex gap-2">
<input id="share-new-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyShareUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
</div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Active links</span>
<button onclick="generateShareLink()" class="text-sm text-seismo-orange hover:text-seismo-navy font-medium">+ Generate new link</button>
</div>
<div id="share-list" class="space-y-2 max-h-56 overflow-y-auto"></div>
</div>
</div>
<script>
const SHARE_PROJECT_ID = "{{ project_id }}";
function openShareModal() {
document.getElementById('share-modal').classList.remove('hidden');
document.getElementById('share-new').classList.add('hidden');
const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on
if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
loadShareLinks();
}
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
function copyOpenUrl(btn) {
const inp = document.getElementById('open-url');
inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); }
}
async function loadShareLinks() {
const list = document.getElementById('share-list');
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';
try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-links`)).json();
if (!j.links || !j.links.length) {
list.innerHTML = '<div class="text-sm text-gray-400">No links yet — generate one above.</div>';
return;
}
list.innerHTML = '';
for (const l of j.links) {
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used';
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
row.innerHTML = `<div class="text-sm min-w-0">
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div>
<div class="text-xs text-gray-400">${last}</div></div>`;
const btn = document.createElement('button');
btn.className = 'shrink-0 text-xs text-red-600 hover:text-red-700';
btn.textContent = 'Revoke';
btn.onclick = () => revokeShareLink(l.id);
row.appendChild(btn);
list.appendChild(row);
}
} catch (e) {
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>';
}
}
async function generateShareLink() {
try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link`, { method: 'POST' })).json();
if (j.url) {
document.getElementById('share-new').classList.remove('hidden');
document.getElementById('share-new-url').value = j.url;
loadShareLinks();
}
} catch (e) {
if (window.showToast) showToast('Failed to generate link', 'error');
}
}
function copyShareUrl(btn) {
const inp = document.getElementById('share-new-url');
inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); }
}
async function revokeShareLink(id) {
if (!confirm('Revoke this link? Anyone using it will be signed out on their next action.')) return;
try { await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link/${id}/revoke`, { method: 'POST' }); loadShareLinks(); }
catch (e) { if (window.showToast) showToast('Failed to revoke', 'error'); }
}
</script> </script>
{% endblock %} {% endblock %}
+1 -70
View File
@@ -122,21 +122,6 @@
How often the dashboard should refresh automatically How often the dashboard should refresh automatically
</p> </p>
</div> </div>
<!-- Event-Report Mic Units -->
<div>
<label for="mic-unit-pref" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Event Report — Mic Channel Units
</label>
<select id="mic-unit-pref"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<option value="psi" selected>psi — raw pressure (matches PDF report)</option>
<option value="dBL">dB(L) — sound pressure level</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
</p>
</div>
</div> </div>
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"> <button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
@@ -472,20 +457,6 @@
<button onclick="saveCalibrationDefaults()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"> <button onclick="saveCalibrationDefaults()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Save Defaults Save Defaults
</button> </button>
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Sync from SFM events</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
Reads <code>calibration_date</code> from each seismograph's most recent event sidecar and updates
<em>Last Calibrated</em> when the device reports a newer date than what's stored.
Manual edits made after the latest event are preserved. Runs automatically once a day.
</p>
<button onclick="runCalibrationSync()" id="cal-sync-btn"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
Sync now
</button>
<div id="cal-sync-result" class="mt-3 text-sm text-gray-700 dark:text-gray-300"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -800,9 +771,6 @@ async function loadPreferences() {
// Load auto-refresh interval // Load auto-refresh interval
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10; document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
// Load event-report mic units
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'psi';
// Load status thresholds // Load status thresholds
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12; document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24; document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
@@ -820,7 +788,6 @@ async function saveGeneralSettings() {
const timezone = document.getElementById('timezone-select').value; const timezone = document.getElementById('timezone-select').value;
const theme = document.querySelector('input[name="theme"]:checked').value; const theme = document.querySelector('input[name="theme"]:checked').value;
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value); const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
const micUnitPref = document.getElementById('mic-unit-pref').value;
try { try {
const response = await fetch('/api/settings/preferences', { const response = await fetch('/api/settings/preferences', {
@@ -829,8 +796,7 @@ async function saveGeneralSettings() {
body: JSON.stringify({ body: JSON.stringify({
timezone, timezone,
theme, theme,
auto_refresh_interval: autoRefreshInterval, auto_refresh_interval: autoRefreshInterval
mic_unit_pref: micUnitPref
}) })
}); });
@@ -904,41 +870,6 @@ async function saveCalibrationDefaults() {
} }
} }
async function runCalibrationSync() {
const btn = document.getElementById('cal-sync-btn');
const out = document.getElementById('cal-sync-result');
btn.disabled = true;
const originalLabel = btn.textContent;
btn.textContent = 'Syncing…';
out.textContent = '';
out.className = 'mt-3 text-sm text-gray-700 dark:text-gray-300';
try {
const response = await fetch('/api/calibration/sync', { method: 'POST' });
const data = await response.json();
if (!response.ok) {
out.className = 'mt-3 text-sm text-red-600 dark:text-red-400';
out.textContent = 'Error: ' + (data.detail || response.statusText);
return;
}
const parts = [
`Checked ${data.checked}`,
`Updated ${data.updated}`,
`Already in sync ${data.already_in_sync}`,
`Manual kept ${data.skipped_manual_newer}`,
`No event ${data.no_event}`,
];
if (data.errors) parts.push(`Errors ${data.errors}`);
out.textContent = parts.join(' · ');
} catch (error) {
out.className = 'mt-3 text-sm text-red-600 dark:text-red-400';
out.textContent = 'Error: ' + error.message;
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
// ========== DATA TAB - IMPORT/EXPORT ========== // ========== DATA TAB - IMPORT/EXPORT ==========
// Merge Mode Import // Merge Mode Import
-7
View File
@@ -118,13 +118,6 @@
{# Shared event-detail modal — rendered by /static/event-modal.js #} {# Shared event-detail modal — rendered by /static/event-modal.js #}
{% include 'partials/event_detail_modal.html' %} {% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script> <script src="/static/event-modal.js"></script>
<script>
// Refresh the events table when the modal's review form saves —
// keeps the FT badge in sync without a full page reload.
window.addEventListener('sfm-event-review-saved', () => {
if (typeof loadEvents === 'function') loadEvents();
});
</script>
<style> <style>
.sfm-tab { .sfm-tab {
-263
View File
@@ -112,267 +112,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Alerts -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">Alerts
<span id="alert-state-badge" class="hidden text-xs px-2 py-0.5 rounded-full"></span>
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">Threshold rules evaluated on this device's live feed. An enabled alert keeps the device monitored 24/7.</p>
</div>
<button onclick="openAlertForm()" type="button"
class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">+ Add alert</button>
</div>
<div id="alert-rules-list" class="space-y-2"></div>
<!-- create / edit form -->
<div id="alert-form" class="hidden mt-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900/40">
<input type="hidden" id="ar-id">
<div class="grid sm:grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Name</label>
<input id="ar-name" type="text" placeholder="e.g. Night noise limit"
class="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-gray-800 dark:text-gray-200">
</div>
<label class="flex items-end gap-2 text-sm text-gray-700 dark:text-gray-300 pb-1">
<input type="checkbox" id="ar-enabled" checked class="rounded"> Enabled
</label>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>Alert when</span>
<select id="ar-metric" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<option value="leq">Leq</option><option value="lp">Lp</option>
<option value="lmax">Lmax</option><option value="lpeak">Lpeak</option>
<option value="ln1">L1</option><option value="ln2">L10</option>
</select>
<span>is</span>
<select id="ar-comparison" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<option value="above">above</option><option value="below">below</option>
</select>
<input id="ar-threshold" type="number" step="0.1" placeholder="65"
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>dB</span>
<span>for</span>
<input id="ar-duration" type="number" min="0" value="0"
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>seconds</span>
</div>
<div class="mt-3">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" id="ar-sched-on" onchange="toggleSchedule()" class="rounded"> Only during certain hours
</label>
<div id="ar-sched" class="hidden mt-2 flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>from</span><input id="ar-start" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<span>to</span><input id="ar-end" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<span class="ml-2">on</span>
<span id="ar-days" class="flex gap-1"></span>
</div>
</div>
<details class="mt-3 text-sm text-gray-600 dark:text-gray-400">
<summary class="cursor-pointer select-none">Advanced</summary>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span>Clear margin</span><input id="ar-margin" type="number" step="0.1" value="2"
class="w-16 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>dB (hysteresis)</span>
<span>Cooldown</span><input id="ar-cooldown" type="number" min="0" value="300"
class="w-20 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>s</span>
</div>
</details>
<div class="mt-4 flex gap-2">
<button onclick="saveAlertRule()" type="button" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Save</button>
<button onclick="closeAlertForm()" type="button" class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700">Cancel</button>
</div>
</div>
<!-- Alert history -->
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">History</h3>
<button onclick="loadAlertEvents()" type="button" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Refresh</button>
</div>
<div id="alert-events" class="space-y-2"></div>
</div>
</div>
<script>
const ALERT_UNIT = "{{ unit_id }}";
const METRIC_LABELS = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Mon=0 .. Sun=6
// Render the day checkboxes once.
(function () {
const wrap = document.getElementById('ar-days');
DAY_LABELS.forEach((lbl, i) => {
const l = document.createElement('label');
l.className = 'inline-flex items-center gap-0.5';
l.innerHTML = `<input type="checkbox" id="ar-day-${i}" class="rounded"><span class="ml-0.5">${lbl}</span>`;
wrap.appendChild(l);
});
})();
function condText(r) {
const m = METRIC_LABELS[r.metric] || r.metric;
let s = `${m} ${r.comparison} ${r.threshold_db} dB`;
if (r.duration_s) s += ` for ${r.duration_s}s`;
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}${r.schedule_end}`;
return s;
}
function renderRule(r) {
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
row.innerHTML = `<div class="min-w-0">
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${r.name}${r.enabled ? '' : ' <span class="text-xs text-gray-400">(disabled)</span>'}</div>
<div class="text-xs text-gray-500">${condText(r)}</div></div>
<div class="shrink-0 flex items-center gap-3 text-xs">
<button data-act="edit" class="text-seismo-orange hover:underline">Edit</button>
<button data-act="del" class="text-red-600 hover:underline">Delete</button>
</div>`;
row.querySelector('[data-act="edit"]').onclick = () => openAlertForm(r);
row.querySelector('[data-act="del"]').onclick = () => deleteAlertRule(r.id);
return row;
}
async function loadAlertRules() {
const list = document.getElementById('alert-rules-list');
try {
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules`)).json();
const rules = j.rules || [];
if (!rules.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts configured.</div>'; return; }
list.innerHTML = '';
rules.forEach(r => list.appendChild(renderRule(r)));
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load alerts.</div>'; }
}
function toggleSchedule() {
document.getElementById('ar-sched').classList.toggle('hidden', !document.getElementById('ar-sched-on').checked);
}
function openAlertForm(r) {
document.getElementById('alert-form').classList.remove('hidden');
document.getElementById('ar-id').value = r ? r.id : '';
document.getElementById('ar-name').value = r ? r.name : '';
document.getElementById('ar-metric').value = r ? r.metric : 'leq';
document.getElementById('ar-comparison').value = r ? r.comparison : 'above';
document.getElementById('ar-threshold').value = (r && r.threshold_db != null) ? r.threshold_db : '';
document.getElementById('ar-duration').value = r ? r.duration_s : 0;
document.getElementById('ar-enabled').checked = r ? r.enabled : true;
document.getElementById('ar-margin').value = r ? r.clear_margin_db : 2;
document.getElementById('ar-cooldown').value = r ? r.cooldown_s : 300;
const hasSched = !!(r && r.schedule_start && r.schedule_end);
document.getElementById('ar-sched-on').checked = hasSched;
document.getElementById('ar-start').value = hasSched ? r.schedule_start : '';
document.getElementById('ar-end').value = hasSched ? r.schedule_end : '';
const days = (r && r.schedule_days) ? r.schedule_days.split(',') : [];
DAY_LABELS.forEach((_, i) => { document.getElementById('ar-day-' + i).checked = days.includes(String(i)); });
toggleSchedule();
}
function closeAlertForm() { document.getElementById('alert-form').classList.add('hidden'); }
async function saveAlertRule() {
const id = document.getElementById('ar-id').value;
const threshold = parseFloat(document.getElementById('ar-threshold').value);
if (isNaN(threshold)) { if (window.showToast) showToast('Enter a threshold', 'error'); return; }
const schedOn = document.getElementById('ar-sched-on').checked;
const days = DAY_LABELS.map((_, i) => document.getElementById('ar-day-' + i).checked ? i : null).filter(v => v !== null);
const payload = {
name: document.getElementById('ar-name').value || 'Alert',
metric: document.getElementById('ar-metric').value,
comparison: document.getElementById('ar-comparison').value,
threshold_db: threshold,
duration_s: parseInt(document.getElementById('ar-duration').value) || 0,
clear_margin_db: parseFloat(document.getElementById('ar-margin').value) || 2,
cooldown_s: parseInt(document.getElementById('ar-cooldown').value) || 300,
schedule_start: schedOn ? (document.getElementById('ar-start').value || null) : null,
schedule_end: schedOn ? (document.getElementById('ar-end').value || null) : null,
schedule_days: (schedOn && days.length) ? days.join(',') : null,
enabled: document.getElementById('ar-enabled').checked,
};
const url = id ? `/api/slmm/${ALERT_UNIT}/alerts/rules/${id}` : `/api/slmm/${ALERT_UNIT}/alerts/rules`;
try {
const r = await fetch(url, { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!r.ok) throw new Error('save failed');
closeAlertForm(); loadAlertRules();
if (window.showToast) showToast('Alert saved', 'success');
} catch (e) { if (window.showToast) showToast('Failed to save alert', 'error'); }
}
async function deleteAlertRule(id) {
if (!confirm('Delete this alert rule?')) return;
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules/${id}`, { method: 'DELETE' }); loadAlertRules(); }
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
}
// ---- alert history (events) ----------------------------------------------
function fmtAlertTime(iso) {
if (!iso) return '';
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString();
}
function updateAlertState(events) {
const badge = document.getElementById('alert-state-badge');
badge.classList.remove('hidden');
const active = events.filter(e => e.status === 'active').length;
if (active) {
badge.textContent = `● ${active} active`;
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
} else {
badge.textContent = '✓ All clear';
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
}
function renderEvent(e) {
const m = METRIC_LABELS[e.metric] || e.metric;
const active = e.status === 'active';
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border ' +
(active ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
: 'border-slate-200 dark:border-slate-700');
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
const ack = e.acknowledged_at ? ` · ack'd${e.acknowledged_by ? ' by ' + e.acknowledged_by : ''}` : '';
row.innerHTML = `<div class="min-w-0">
<div class="text-sm truncate">
<span class="${active ? 'text-red-600 dark:text-red-400 font-medium' : 'text-gray-800 dark:text-gray-200'}">${e.rule_name || 'Alert'}</span>
<span class="text-xs text-gray-500"> · ${m} ${e.threshold_db} dB</span>
</div>
<div class="text-xs text-gray-500">${when}${peak}${ack}</div></div>`;
if (!e.acknowledged_at) {
const btn = document.createElement('button');
btn.className = 'shrink-0 text-xs text-seismo-orange hover:underline';
btn.textContent = 'Ack';
btn.onclick = () => ackEvent(e.id);
row.appendChild(btn);
}
return row;
}
async function loadAlertEvents() {
const list = document.getElementById('alert-events');
try {
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events?limit=50`)).json();
const events = j.events || [];
updateAlertState(events);
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts have fired.</div>'; return; }
list.innerHTML = '';
events.forEach(e => list.appendChild(renderEvent(e)));
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load history.</div>'; }
}
async function ackEvent(id) {
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events/${id}/ack`, { method: 'POST' }); loadAlertEvents(); }
catch (e) { if (window.showToast) showToast('Failed to acknowledge', 'error'); }
}
loadAlertRules();
loadAlertEvents();
setInterval(loadAlertEvents, 20000); // surface new breaches / clears
</script>
{% endblock %} {% endblock %}
+40 -281
View File
@@ -51,31 +51,13 @@
<!-- Live Measurement Chart - shows when a device is selected --> <!-- Live Measurement Chart - shows when a device is selected -->
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8"> <div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
<div class="flex items-start justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white"> <button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
Live Measurements <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span id="panel-unit-id" class="text-seismo-orange"></span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</h2> </svg>
<!-- Measuring state + cache freshness (populated from cached /status, no device hit) --> </button>
<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> </div>
<!-- Current Metrics --> <!-- Current Metrics -->
@@ -99,14 +81,14 @@
</div> </div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4"> <div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<p id="chart-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L1</p> <p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
<p id="chart-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p> <p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4"> <div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<p id="chart-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L10</p> <p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
<p id="chart-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p> <p id="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
</div> </div>
@@ -168,18 +150,9 @@ window.selectedUnitId = null;
window.dashboardChartData = { window.dashboardChartData = {
timestamps: [], timestamps: [],
lp: [], lp: [],
leq: [], leq: []
ln1: [],
ln2: []
}; };
// Parse a metric to a number, or null (so a missing/"-.-" percentile leaves a gap
// in the line instead of dropping it to 0).
function numOrNull(v) {
const f = parseFloat(v);
return isNaN(f) ? null : f;
}
// Initialize Chart.js // Initialize Chart.js
function initializeDashboardChart() { function initializeDashboardChart() {
if (typeof Chart === 'undefined') { if (typeof Chart === 'undefined') {
@@ -221,26 +194,6 @@ function initializeDashboardChart() {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0 pointRadius: 0
},
{
label: 'L1',
data: [],
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
spanGaps: true
},
{
label: 'L10',
data: [],
borderColor: 'rgb(249, 115, 22)',
backgroundColor: 'rgba(249, 115, 22, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
spanGaps: true
} }
] ]
}, },
@@ -291,24 +244,12 @@ function showLiveChart(unitId) {
initializeDashboardChart(); initializeDashboardChart();
} }
// Reset data for the newly-selected unit (clears any prior unit's line) // Reset data
window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] }; window.dashboardChartData = {
if (window.dashboardChart) { timestamps: [],
window.dashboardChart.data.labels = []; lp: [],
window.dashboardChart.data.datasets.forEach(ds => ds.data = []); leq: []
window.dashboardChart.update('none'); };
}
// Name the unit; clear stale status until the cache read returns
const unitLabel = document.getElementById('panel-unit-id');
if (unitLabel) unitLabel.textContent = '· ' + unitId;
setPanelStatus(null, null);
// Populate immediately from CACHE (no device hit): KPI cards + chart trail.
prefillDashboardPanel(unitId);
backfillDashboardChart(unitId);
// Keep the cards updating from cache (~15s) without opening a device stream.
startPanelCachePolling(unitId);
// Scroll to chart // Scroll to chart
panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -316,7 +257,6 @@ function showLiveChart(unitId) {
function closeLiveChart() { function closeLiveChart() {
stopDashboardStream(); stopDashboardStream();
stopPanelCachePolling();
document.getElementById('live-chart-panel').classList.add('hidden'); document.getElementById('live-chart-panel').classList.add('hidden');
window.selectedUnitId = null; window.selectedUnitId = null;
} }
@@ -330,12 +270,17 @@ function startDashboardStream() {
window.dashboardWebSocket.close(); window.dashboardWebSocket.close();
} }
// The live WS takes over from the cache poller; keep the backfilled trail on // Reset chart data
// the chart so the live frames continue the line instead of blanking it. window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
stopPanelCachePolling(); if (window.dashboardChart) {
window.dashboardChart.data.labels = [];
window.dashboardChart.data.datasets[0].data = [];
window.dashboardChart.data.datasets[1].data = [];
window.dashboardChart.update();
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`; const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
window.dashboardWebSocket = new WebSocket(wsUrl); window.dashboardWebSocket = new WebSocket(wsUrl);
@@ -348,10 +293,6 @@ function startDashboardStream() {
window.dashboardWebSocket.onmessage = function(event) { window.dashboardWebSocket.onmessage = function(event) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// /monitor sends keepalive 'heartbeat' frames (no metrics) and a per-frame
// 'feed_status'; skip heartbeats and offline frames so they don't blank the
// metrics or spike the chart with zeros.
if (data.heartbeat || data.feed_status === 'unreachable') return;
updateDashboardMetrics(data); updateDashboardMetrics(data);
updateDashboardChart(data); updateDashboardChart(data);
} catch (error) { } catch (error) {
@@ -375,219 +316,37 @@ function stopDashboardStream() {
window.dashboardWebSocket.close(); window.dashboardWebSocket.close();
window.dashboardWebSocket = null; window.dashboardWebSocket = null;
} }
// Fall back to cache polling so the cards keep refreshing while the panel is open.
if (window.selectedUnitId && !document.getElementById('live-chart-panel').classList.contains('hidden')) {
startPanelCachePolling(window.selectedUnitId);
}
} }
function updateDashboardMetrics(data) { function updateDashboardMetrics(data) {
document.getElementById('chart-lp').textContent = data.lp || '--'; document.getElementById('chart-lp').textContent = data.lp || '--';
document.getElementById('chart-leq').textContent = data.leq || '--'; document.getElementById('chart-leq').textContent = data.leq || '--';
document.getElementById('chart-lmax').textContent = data.lmax || '--'; document.getElementById('chart-lmax').textContent = data.lmax || '--';
// Guard: DRD stream frames omit percentiles, so only overwrite when present document.getElementById('chart-lmin').textContent = data.lmin || '--';
// (else the live stream blanks L1/L10 over the cached DOD snapshot values). document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
if (data.ln1 != null) document.getElementById('chart-ln1').textContent = data.ln1;
if (data.ln2 != null) document.getElementById('chart-ln2').textContent = data.ln2;
if (data.ln1_label) document.getElementById('chart-ln1-label').textContent = data.ln1_label;
if (data.ln2_label) document.getElementById('chart-ln2-label').textContent = data.ln2_label;
} }
function updateDashboardChart(data) { function updateDashboardChart(data) {
const cd = window.dashboardChartData;
const now = new Date(); const now = new Date();
cd.timestamps.push(now.toLocaleTimeString()); window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
cd.lp.push(numOrNull(data.lp)); window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
cd.leq.push(numOrNull(data.leq)); window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
// /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 a generous window (backfill seeds up to ~120 points from the 2h trail). // Keep only last 60 data points
if (cd.timestamps.length > 600) { if (window.dashboardChartData.timestamps.length > 60) {
cd.timestamps.shift(); window.dashboardChartData.timestamps.shift();
cd.lp.shift(); window.dashboardChartData.lp.shift();
cd.leq.shift(); window.dashboardChartData.leq.shift();
cd.ln1.shift();
cd.ln2.shift();
} }
if (window.dashboardChart) { if (window.dashboardChart) {
window.dashboardChart.data.labels = cd.timestamps; window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
window.dashboardChart.data.datasets[0].data = cd.lp; window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
window.dashboardChart.data.datasets[1].data = cd.leq; window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
window.dashboardChart.data.datasets[2].data = cd.ln1;
window.dashboardChart.data.datasets[3].data = cd.ln2;
window.dashboardChart.update('none'); window.dashboardChart.update('none');
} }
} }
// ---- Cached-data panel population (no device hit) -----------------------
// Fill the KPI cards + measuring/freshness from the cached NL43Status snapshot.
async function prefillDashboardPanel(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/status`);
if (!r.ok) { // 404 = device has never reported yet
setPanelStatus(null, null);
return;
}
const d = (await r.json()).data || {};
updateDashboardMetrics(d); // lp/leq/lmax/ln1/ln2 (ln guards keep cached percentiles)
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setPanelStatus(measuring, d.last_seen);
} catch (e) {
console.warn('Panel cache prefill failed:', e);
}
}
// Seed the chart from the downsampled DOD trail so it shows recent trend on open.
async function backfillDashboardChart(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
if (!r.ok) return;
const readings = (await r.json()).readings || [];
const cd = window.dashboardChartData;
if (!cd) return;
for (const row of readings) {
// Trail timestamps are naive UTC; append 'Z' to render in local time
// consistently with the live frames (which use local Date.now()).
cd.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
cd.lp.push(numOrNull(row.lp));
cd.leq.push(numOrNull(row.leq));
cd.ln1.push(numOrNull(row.ln1));
cd.ln2.push(numOrNull(row.ln2));
}
if (window.dashboardChart) {
window.dashboardChart.data.labels = cd.timestamps;
window.dashboardChart.data.datasets[0].data = cd.lp;
window.dashboardChart.data.datasets[1].data = cd.leq;
window.dashboardChart.data.datasets[2].data = cd.ln1;
window.dashboardChart.data.datasets[3].data = cd.ln2;
window.dashboardChart.update('none');
}
} catch (e) {
console.warn('Panel chart backfill failed:', e);
}
}
// Measuring badge + "as of <time> (Xm ago)" freshness, so a cached value is never
// mistaken for a live one. measuring: true | false | null(unknown).
function setPanelStatus(measuring, lastSeenIso) {
const badge = document.getElementById('panel-measuring-badge');
const fresh = document.getElementById('panel-freshness');
if (badge) {
if (measuring === null) {
badge.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full';
badge.textContent = '';
} else if (measuring) {
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
badge.textContent = '● Measuring';
} else {
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
badge.textContent = '■ Stopped';
}
}
if (fresh) fresh.innerHTML = fmtFreshness(lastSeenIso);
}
// Human "x ago" with a staleness hint. Cached timestamps are naive UTC.
function fmtFreshness(lastSeenIso) {
if (!lastSeenIso) return '<span class="text-gray-400">no cached reading yet</span>';
const t = new Date(lastSeenIso.endsWith('Z') ? lastSeenIso : lastSeenIso + 'Z');
const secs = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
let ago, stale = false;
if (secs < 10) ago = 'just now';
else if (secs < 60) ago = secs + 's ago';
else if (secs < 3600) { ago = Math.round(secs / 60) + 'm ago'; stale = secs >= 300; }
else { ago = Math.round(secs / 3600) + 'h ago'; stale = true; }
const cls = stale ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400';
const tag = stale ? ' · cached' : '';
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${tag})</span>`;
}
// Cache polling: refresh the cards from cache every 15s while the panel is open
// and not live-streaming. Pure cache reads — no device contention.
function startPanelCachePolling(unitId) {
stopPanelCachePolling();
window.panelCacheTimer = setInterval(() => {
if (window.selectedUnitId) prefillDashboardPanel(window.selectedUnitId);
}, 15000);
}
function stopPanelCachePolling() {
if (window.panelCacheTimer) { clearInterval(window.panelCacheTimer); window.panelCacheTimer = null; }
}
// ---- On-demand device refresh (the per-unit + panel refresh buttons) -----
// One bounded, user-initiated device read: hits the device, updates the cache,
// returns the fresh data. Throws on unreachable/disabled.
async function forceDeviceRead(unitId) {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/live`);
if (!r.ok) {
let detail = 'device unreachable';
try { detail = (await r.json()).detail || detail; } catch (e) {}
throw new Error(detail);
}
return (await r.json()).data || {};
}
function spinIcon(el, on) {
if (el) el.classList.toggle('animate-spin', on);
}
function applyFreshReadToPanel(unitId, d) {
if (window.selectedUnitId !== unitId) return;
updateDashboardMetrics(d);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
// The read just happened, so "now" is the accurate freshness even if the
// /live payload doesn't echo last_seen.
setPanelStatus(measuring, d.last_seen || new Date().toISOString());
}
// Device-list row refresh button.
async function refreshSlmUnit(unitId, btn) {
const icon = btn ? btn.querySelector('svg') : null;
if (btn) btn.disabled = true;
spinIcon(icon, true);
try {
const d = await forceDeviceRead(unitId);
applyFreshReadToPanel(unitId, d);
// Reload the list so the row's badge + last-check reflect the new cache.
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
htmx.trigger('#slm-devices-list', 'load');
}
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
} catch (e) {
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
else console.warn('refresh failed', e);
} finally {
if (btn) btn.disabled = false;
spinIcon(icon, false);
}
}
// Panel header refresh button (refreshes the unit the panel is showing).
async function refreshDashboardPanel() {
const unitId = window.selectedUnitId;
if (!unitId) return;
const icon = document.getElementById('panel-refresh-icon');
spinIcon(icon, true);
try {
const d = await forceDeviceRead(unitId);
applyFreshReadToPanel(unitId, d);
updateDashboardChart(d); // append the fresh point to the chart
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
htmx.trigger('#slm-devices-list', 'load');
}
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
} catch (e) {
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
} finally {
spinIcon(icon, false);
}
}
// Configuration modal - use unified SLM settings modal // Configuration modal - use unified SLM settings modal
function openDeviceConfigModal(unitId) { function openDeviceConfigModal(unitId) {
// Call the unified modal function from slm_settings_modal.html // Call the unified modal function from slm_settings_modal.html
+35 -53
View File
@@ -129,15 +129,6 @@
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span> <span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
</p> </p>
</div> </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> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label> <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> <p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
@@ -648,12 +639,18 @@
{% include "partials/project_picker.html" with context %} {% include "partials/project_picker.html" with context %}
</div> </div>
<!-- Address / coordinates are managed on the project's <!-- Address -->
MonitoringLocation, not the unit itself. Edit them on <div>
the project page. --> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<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"> <input type="text" name="address" id="address" placeholder="123 Main St, City, State"
Address &amp; coordinates are set on the deployment location. 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">
Open the project to edit them. </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">
</div> </div>
</div> </div>
@@ -851,6 +848,16 @@
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded"> 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> <span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label> </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"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true" <input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded"> class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
@@ -1161,28 +1168,8 @@ function populateViewMode() {
if (projectLink) projectLink.classList.add('hidden'); if (projectLink) projectLink.classList.add('hidden');
} }
// Deployment Location — comes from the active UnitAssignment → document.getElementById('viewAddress').textContent = currentUnit.address || '--';
// MonitoringLocation. Show project link if present, otherwise document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
// "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 // Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--'; document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
@@ -1340,6 +1327,8 @@ function populateEditForm() {
if (projectPickerClear) projectPickerClear.classList.add('hidden'); 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('deployed').checked = currentUnit.deployed;
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false; document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
document.getElementById('retired').value = currentUnit.retired ? 'true' : ''; document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
@@ -1620,13 +1609,8 @@ function initUnitMap() {
// Update marker (can be called multiple times) // Update marker (can be called multiple times)
updateMapMarker(lat, lon); updateMapMarker(lat, lon);
// Update location text — prefer the assignment's location name, fall // Update location text
// back to address, then coordinates.
const locationParts = []; const locationParts = [];
const loc = currentUnit.active_location;
if (loc && loc.name) {
locationParts.push(loc.name);
}
if (currentUnit.address) { if (currentUnit.address) {
locationParts.push(currentUnit.address); locationParts.push(currentUnit.address);
} }
@@ -1740,12 +1724,13 @@ async function uploadPhoto(file) {
const result = await response.json(); const result = await response.json();
// Show success message with metadata info. Location is on the // Show success message with metadata info
// 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!'; let message = 'Photo uploaded successfully!';
if (result.metadata && result.metadata.coordinates) { if (result.metadata && result.metadata.coordinates) {
message += ` GPS location detected: ${result.metadata.coordinates}`; message += ` GPS location detected: ${result.metadata.coordinates}`;
if (result.coordinates_updated) {
message += ' (Unit coordinates updated automatically)';
}
} else { } else {
message += ' No GPS data found in photo.'; message += ' No GPS data found in photo.';
} }
@@ -1753,8 +1738,11 @@ 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.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
statusDiv.textContent = message; statusDiv.textContent = message;
// Reload photos // Reload photos and unit data
await loadPhotos(); await loadPhotos();
if (result.coordinates_updated) {
await loadUnitData();
}
// Hide status after 5 seconds // Hide status after 5 seconds
setTimeout(() => { setTimeout(() => {
@@ -3732,11 +3720,5 @@ function showToast(message, type = 'info') {
{# Shared event-detail modal (clicking a row in the SFM Events table) #} {# Shared event-detail modal (clicking a row in the SFM Events table) #}
{% include 'partials/event_detail_modal.html' %} {% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script> <script src="/static/event-modal.js"></script>
<script>
// Refresh the unit's events table when the modal's review form saves.
window.addEventListener('sfm-event-review-saved', () => {
if (typeof loadUnitEvents === 'function') loadUnitEvents();
});
</script>
{% endblock %} {% endblock %}
-6
View File
@@ -992,10 +992,4 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
{# Shared event-detail modal (clicking an event row in the Events tab) #} {# Shared event-detail modal (clicking an event row in the Events tab) #}
{% include 'partials/event_detail_modal.html' %} {% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script> <script src="/static/event-modal.js"></script>
<script>
// Refresh the location's events table when the modal's review form saves.
window.addEventListener('sfm-event-review-saved', () => {
if (typeof loadLocationEvents === 'function') loadLocationEvents();
});
</script>
{% endblock %} {% endblock %}