Compare commits
16 Commits
472c25372d
...
v0.12.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2086d6bb | |||
| 623ef648b7 | |||
| 5ed00bf70e | |||
| 43c804d0c4 | |||
| c1f995b4d3 | |||
| 2905a327be | |||
| 4b2bb9a9c9 | |||
| 1d9fd00cc2 | |||
| db8d666aa1 | |||
| b2bfa6d268 | |||
| d0685baed5 | |||
| d64b9450a1 | |||
| a073b9b06e | |||
| 502bf5bbeb | |||
| 275a168046 | |||
| f4fd1c943d |
+130
@@ -5,6 +5,136 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.13.2] - 2026-05-30
|
||||||
|
|
||||||
|
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
|
||||||
|
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
|
||||||
|
|
||||||
|
### What mobile users will see after deploy
|
||||||
|
|
||||||
|
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.1] - 2026-05-29
|
||||||
|
|
||||||
|
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
|
||||||
|
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-05-29
|
||||||
|
|
||||||
|
The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
|
||||||
|
|
||||||
|
### Added — Event-detail modal: Chart.js waveform/histogram panels
|
||||||
|
|
||||||
|
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
|
||||||
|
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
|
||||||
|
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
|
||||||
|
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
|
||||||
|
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
|
||||||
|
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
|
||||||
|
|
||||||
|
### Added — Event-detail modal: PDF preview + downloads + review form
|
||||||
|
|
||||||
|
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
|
||||||
|
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
|
||||||
|
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
|
||||||
|
|
||||||
|
### Added — `/admin/events` row click opens the modal
|
||||||
|
|
||||||
|
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
|
||||||
|
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
|
||||||
|
|
||||||
|
### Added — `mic_unit_pref` user setting (Settings → General)
|
||||||
|
|
||||||
|
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
|
||||||
|
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
|
||||||
|
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
|
||||||
|
|
||||||
|
### Fixed — Docker Compose: SFM container can finally read the DB
|
||||||
|
|
||||||
|
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
# Apply the new column to the database — required. Idempotent.
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||||
|
|
||||||
|
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
|
||||||
|
docker compose build terra-view && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
|
||||||
|
|
||||||
|
### What's NOT in this release
|
||||||
|
|
||||||
|
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12.1] - 2026-05-20
|
||||||
|
|
||||||
|
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
|
||||||
|
|
||||||
|
### Added — Unit Swap wizard (`/tools/unit-swap`)
|
||||||
|
|
||||||
|
- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm. Designed for tap-driven use on a phone in the field; works on desktop too.
|
||||||
|
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf. Each row carries a `deployed` boolean for badge rendering. Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
|
||||||
|
- **`POST /locations/{loc}/swap` enhancements**:
|
||||||
|
- Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` flag consistent with the active-assignment signal.
|
||||||
|
- Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), the stale back-reference is broken before re-pairing.
|
||||||
|
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
|
||||||
|
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.
|
||||||
|
|
||||||
|
### Added — Editable deployment timeline on `/unit/{id}`
|
||||||
|
|
||||||
|
- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline. Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes. Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
|
||||||
|
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment. Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
|
||||||
|
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows. Closed historical assignments that don't overlap an existing one are accepted (the backfill case).
|
||||||
|
- After any save/delete the timeline reloads and the SFM-events list re-fetches, so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`). The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever. All three now: close the previous active assignment, break the outgoing unit's modem pairing (both directions), and set `deployed = False` on the outgoing unit. Unassign and swap also clear the modem's back-reference. Promote-pending additionally handles the case where the target location already has an active assignment — previously this silently created two active assignments at the same location; now the old one is closed (`assigned_until = pending.capture_time`, `status = completed`), the old unit benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
|
||||||
|
- **Deployment timeline now respects user timezone for display *and* edits.** Timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen. Two-sided fix:
|
||||||
|
- **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing. Frontend slicing keeps working — it just slices a local-time string now.
|
||||||
|
- **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`. Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
No schema changes. Static code-only release — pull and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.12.0] - 2026-05-17
|
## [0.12.0] - 2026-05-17
|
||||||
|
|
||||||
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
|
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.12.0
|
# Terra-View v0.13.2
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -9,15 +9,20 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
|
|||||||
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
||||||
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
||||||
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
||||||
|
- **Field-Deployment Workflow**: One-photo mobile capture at `/deploy` → desk-side classification at `/tools/pending-deployments` → automatic UnitAssignment creation with EXIF GPS
|
||||||
|
- **Unit Swap Wizard** (`/tools/unit-swap`): mobile-first 4-step flow for swapping a vibration unit (and optionally its modem) at a monitoring location. Surfaces benched-fleet candidates as eligible incoming units; cleans up stale modem back-references on swap
|
||||||
|
- **Editable Deployment Timeline** on every unit detail page: inline edit / delete each assignment, plus an "Add deployment record" button for backfilling historical windows. Frees-up previously-orphaned events when their timestamp now falls inside an assignment
|
||||||
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
||||||
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
||||||
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
||||||
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
||||||
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
||||||
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
||||||
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
- **SFM Event DB Manager** (`/admin/events`): cross-unit event browser with bulk false-trigger flagging and admin-only hard-delete (cleans on-disk binaries + sidecars too) for purging bogus events from misbehaving units
|
||||||
|
- **Deployment-History Calendar + Gantt** (`/tools/deployment-history`): fleet-wide 12-month calendar with side-panel day drill-down, plus "Gantt by Project" / "Gantt by Unit" tabs
|
||||||
- **Photo Management**: Upload and view photos for each unit
|
- **Photo Management**: Upload and view photos for each unit
|
||||||
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab)
|
||||||
|
- **Timezone-Aware Timeline**: deployment assignments display and edit in the user's configured local timezone; UTC stays canonical on disk
|
||||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||||
- **Database Management**: Comprehensive backup and restore system
|
- **Database Management**: Comprehensive backup and restore system
|
||||||
- **Manual Snapshots**: Create on-demand backups with descriptions
|
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||||
|
|||||||
+1
-1
@@ -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.12.0"
|
VERSION = "0.13.2"
|
||||||
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":
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Add mic_unit_pref column to user_preferences.
|
||||||
|
|
||||||
|
Adds a single field controlling the mic channel's unit on the event-
|
||||||
|
report waveform chart in the SFM event detail modal. "psi" (default —
|
||||||
|
matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles
|
||||||
|
elsewhere are always dBL regardless.
|
||||||
|
|
||||||
|
History: v0.13.0 originally shipped this with default "dBL", which
|
||||||
|
made the website chart inconsistent with the PDF. v0.13.1 flips the
|
||||||
|
default to "psi" so they match. This migration is idempotent and
|
||||||
|
covers three cases:
|
||||||
|
|
||||||
|
1. Fresh DB without the column — adds it with default 'psi'.
|
||||||
|
2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to
|
||||||
|
'psi' on the assumption no operator deliberately picked 'dBL' yet.
|
||||||
|
3. DB upgraded from later — flip step is a no-op for non-'dBL' values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
possible_paths = [
|
||||||
|
Path("data/seismo_fleet.db"),
|
||||||
|
Path("data/sfm.db"),
|
||||||
|
Path("data/seismo.db"),
|
||||||
|
]
|
||||||
|
db_path = next((p for p in possible_paths if p.exists()), None)
|
||||||
|
if db_path is None:
|
||||||
|
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||||
|
print("Will be created with the new column when models.py initialises.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("PRAGMA table_info(user_preferences)")
|
||||||
|
existing = {row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if "mic_unit_pref" not in existing:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE user_preferences "
|
||||||
|
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
|
||||||
|
)
|
||||||
|
# Backfill any rows where the column ended up NULL.
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||||
|
"WHERE mic_unit_pref IS NULL"
|
||||||
|
)
|
||||||
|
print("Added mic_unit_pref column (default 'psi').")
|
||||||
|
else:
|
||||||
|
print("mic_unit_pref column already exists.")
|
||||||
|
|
||||||
|
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
|
||||||
|
# 'dBL' default get bumped to 'psi'. If any operator deliberately
|
||||||
|
# chose 'dBL' through Settings before this migration runs they'd
|
||||||
|
# get reset — acceptable trade-off given the small user base and
|
||||||
|
# the fact the setting is one click to restore.
|
||||||
|
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||||
|
"WHERE mic_unit_pref = 'dBL'")
|
||||||
|
flipped = cur.rowcount
|
||||||
|
if flipped:
|
||||||
|
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -135,6 +135,11 @@ class UserPreferences(Base):
|
|||||||
calibration_warning_days = Column(Integer, default=30)
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
status_ok_threshold_hours = Column(Integer, default=12)
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
status_pending_threshold_hours = Column(Integer, default=24)
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
|
# Mic display units on the event-report waveform chart only — peaks
|
||||||
|
# and KPI tiles elsewhere are always dBL. "psi" (default — matches
|
||||||
|
# the PDF report) or "dBL". Default flipped in v0.13.1 after
|
||||||
|
# operator feedback that the chart should mirror the PDF.
|
||||||
|
mic_unit_pref = Column(String, default="psi")
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,36 @@ async def promote_pending(
|
|||||||
db.add(location)
|
db.add(location)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
# If this location already has an active assignment, the /deploy
|
||||||
|
# capture means someone replaced that unit in the field — close the
|
||||||
|
# old assignment, break the outgoing unit's modem pairing, and bench
|
||||||
|
# it so the heartbeat / polling subsystem stops chasing it.
|
||||||
|
existing_active = db.query(UnitAssignment).filter(
|
||||||
|
UnitAssignment.location_id == location.id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
).first()
|
||||||
|
if existing_active and existing_active.unit_id != pd.unit_id:
|
||||||
|
existing_active.assigned_until = pd.captured_at
|
||||||
|
existing_active.status = "completed"
|
||||||
|
old_unit = db.query(RosterUnit).filter_by(id=existing_active.unit_id).first()
|
||||||
|
if old_unit:
|
||||||
|
if old_unit.deployed_with_modem_id:
|
||||||
|
old_modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=old_unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if old_modem and old_modem.deployed_with_unit_id == old_unit.id:
|
||||||
|
old_modem.deployed_with_unit_id = None
|
||||||
|
old_unit.deployed_with_modem_id = None
|
||||||
|
if old_unit.deployed:
|
||||||
|
old_unit.deployed = False
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=existing_active.unit_id,
|
||||||
|
change_type="assignment_swapped",
|
||||||
|
old_value=location.name,
|
||||||
|
new_value=f"superseded by /deploy capture → {pd.unit_id}",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
# Create the assignment. assigned_at = pending capture time (so
|
# Create the assignment. assigned_at = pending capture time (so
|
||||||
# events emitted after the install are correctly attributed back
|
# events emitted after the install are correctly attributed back
|
||||||
# to this location).
|
# to this location).
|
||||||
@@ -354,6 +384,12 @@ async def promote_pending(
|
|||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
# Incoming unit is in the field again — flip it back to deployed
|
||||||
|
# if it was on the bench (mirrors the swap endpoint).
|
||||||
|
incoming_unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
|
||||||
|
if incoming_unit and not incoming_unit.deployed:
|
||||||
|
incoming_unit.deployed = True
|
||||||
|
|
||||||
# Promote the pending row.
|
# Promote the pending row.
|
||||||
pd.status = "assigned"
|
pd.status = "assigned"
|
||||||
pd.promoted_at = datetime.utcnow()
|
pd.promoted_at = datetime.utcnow()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
@@ -34,7 +35,7 @@ from backend.models import (
|
|||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
from backend.utils.timezone import local_to_utc
|
from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
|
|
||||||
@@ -761,27 +762,22 @@ async def assign_unit_to_location(
|
|||||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse dates.
|
# Parse dates. Naive datetimes from datetime-local inputs are
|
||||||
assigned_at_str = form_data.get("assigned_at")
|
# interpreted as user-local and converted to UTC for storage; explicit
|
||||||
assigned_until_str = form_data.get("assigned_until")
|
# tz-aware ISO strings (Z / +00:00) skip the conversion.
|
||||||
try:
|
def _parse_user_dt(s: str | None, field: str):
|
||||||
assigned_at = (
|
if not s:
|
||||||
datetime.fromisoformat(assigned_at_str)
|
return None
|
||||||
if assigned_at_str else datetime.utcnow()
|
try:
|
||||||
)
|
parsed = datetime.fromisoformat(s)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}")
|
||||||
status_code=400, detail=f"Invalid assigned_at: {assigned_at_str!r}"
|
if parsed.tzinfo is None:
|
||||||
)
|
return local_to_utc(parsed)
|
||||||
try:
|
return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
assigned_until = (
|
|
||||||
datetime.fromisoformat(assigned_until_str)
|
assigned_at = _parse_user_dt(form_data.get("assigned_at"), "assigned_at") or datetime.utcnow()
|
||||||
if assigned_until_str else None
|
assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until")
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail=f"Invalid assigned_until: {assigned_until_str!r}"
|
|
||||||
)
|
|
||||||
if assigned_until is not None and assigned_until <= assigned_at:
|
if assigned_until is not None and assigned_until <= assigned_at:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="assigned_until must be after assigned_at.",
|
status_code=400, detail="assigned_until must be after assigned_at.",
|
||||||
@@ -876,6 +872,20 @@ async def unassign_unit(
|
|||||||
assignment.status = "completed"
|
assignment.status = "completed"
|
||||||
assignment.assigned_until = datetime.utcnow()
|
assignment.assigned_until = datetime.utcnow()
|
||||||
|
|
||||||
|
# Unit is leaving the field — bench it so the heartbeat / polling
|
||||||
|
# subsystem stops chasing it. Also break the modem pairing both ways.
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
if unit:
|
||||||
|
if unit.deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem and modem.deployed_with_unit_id == unit.id:
|
||||||
|
modem.deployed_with_unit_id = None
|
||||||
|
unit.deployed_with_modem_id = None
|
||||||
|
if unit.deployed:
|
||||||
|
unit.deployed = False
|
||||||
|
|
||||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||||
_record_assignment_history(
|
_record_assignment_history(
|
||||||
db,
|
db,
|
||||||
@@ -910,6 +920,11 @@ async def update_assignment(
|
|||||||
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
||||||
- notes: string
|
- notes: string
|
||||||
|
|
||||||
|
Naive datetimes (no tz suffix) are interpreted as the user's
|
||||||
|
configured timezone and converted to UTC for storage. Send an
|
||||||
|
explicit "+00:00" / "Z" suffix to skip the conversion (programmatic
|
||||||
|
callers that already have UTC).
|
||||||
|
|
||||||
Sets `status` to "active" when assigned_until is cleared, "completed"
|
Sets `status` to "active" when assigned_until is cleared, "completed"
|
||||||
when it's set in the past.
|
when it's set in the past.
|
||||||
"""
|
"""
|
||||||
@@ -940,12 +955,14 @@ async def update_assignment(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
||||||
new_assigned_at = datetime.fromisoformat(raw)
|
parsed = datetime.fromisoformat(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid assigned_at datetime: {raw!r}",
|
detail=f"Invalid assigned_at datetime: {raw!r}",
|
||||||
)
|
)
|
||||||
|
# Naive (no tz) → treat as user's local time and store as UTC.
|
||||||
|
new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
if "assigned_until" in payload:
|
if "assigned_until" in payload:
|
||||||
raw = payload["assigned_until"]
|
raw = payload["assigned_until"]
|
||||||
@@ -953,12 +970,13 @@ async def update_assignment(
|
|||||||
new_assigned_until = None
|
new_assigned_until = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
new_assigned_until = datetime.fromisoformat(raw)
|
parsed = datetime.fromisoformat(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid assigned_until datetime: {raw!r}",
|
detail=f"Invalid assigned_until datetime: {raw!r}",
|
||||||
)
|
)
|
||||||
|
new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
if "notes" in payload:
|
if "notes" in payload:
|
||||||
raw = payload["notes"]
|
raw = payload["notes"]
|
||||||
@@ -1320,13 +1338,18 @@ async def swap_unit_on_location(
|
|||||||
# deployed_with_modem_id / deployed_with_unit_id back-reference
|
# deployed_with_modem_id / deployed_with_unit_id back-reference
|
||||||
# doesn't orphan onto the unit that just left the field.
|
# doesn't orphan onto the unit that just left the field.
|
||||||
old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first()
|
old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first()
|
||||||
if old_unit and old_unit.deployed_with_modem_id:
|
if old_unit:
|
||||||
old_modem = db.query(RosterUnit).filter_by(
|
if old_unit.deployed_with_modem_id:
|
||||||
id=old_unit.deployed_with_modem_id, device_type="modem"
|
old_modem = db.query(RosterUnit).filter_by(
|
||||||
).first()
|
id=old_unit.deployed_with_modem_id, device_type="modem"
|
||||||
if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
|
).first()
|
||||||
old_modem.deployed_with_unit_id = None
|
if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
|
||||||
old_unit.deployed_with_modem_id = None
|
old_modem.deployed_with_unit_id = None
|
||||||
|
old_unit.deployed_with_modem_id = None
|
||||||
|
# Bench the outgoing unit — it's no longer in the field, so
|
||||||
|
# the heartbeat / polling subsystem should stop chasing it.
|
||||||
|
if old_unit.deployed:
|
||||||
|
old_unit.deployed = False
|
||||||
|
|
||||||
# Create new assignment
|
# Create new assignment
|
||||||
new_assignment = UnitAssignment(
|
new_assignment = UnitAssignment(
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ class PreferencesUpdate(BaseModel):
|
|||||||
calibration_warning_days: Optional[int] = None
|
calibration_warning_days: Optional[int] = None
|
||||||
status_ok_threshold_hours: Optional[int] = None
|
status_ok_threshold_hours: Optional[int] = None
|
||||||
status_pending_threshold_hours: Optional[int] = None
|
status_pending_threshold_hours: Optional[int] = None
|
||||||
|
mic_unit_pref: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences")
|
@router.get("/preferences")
|
||||||
@@ -293,6 +294,7 @@ def get_preferences(db: Session = Depends(get_db)):
|
|||||||
"calibration_warning_days": prefs.calibration_warning_days,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
||||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +336,7 @@ def update_preferences(
|
|||||||
"calibration_warning_days": prefs.calibration_warning_days,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
||||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,35 @@ from backend.services.sfm_events import (
|
|||||||
_fetch_events_for_serial,
|
_fetch_events_for_serial,
|
||||||
_iso_utc,
|
_iso_utc,
|
||||||
)
|
)
|
||||||
|
from backend.utils.timezone import utc_to_local
|
||||||
|
|
||||||
log = logging.getLogger("backend.services.deployment_timeline")
|
log = logging.getLogger("backend.services.deployment_timeline")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_local(dt) -> Optional[str]:
|
||||||
|
"""Serialize a datetime / ISO-string in the user's configured timezone.
|
||||||
|
|
||||||
|
The timeline frontend slices these strings to character 19 to produce
|
||||||
|
"YYYY-MM-DD HH:MM:SS" — no JS-side timezone conversion happens. We
|
||||||
|
therefore emit *already-local* timestamps here so the displayed time
|
||||||
|
matches what the operator actually saw on the wall clock.
|
||||||
|
|
||||||
|
Accepts either a ``datetime`` (DB column) or an ISO ``str`` (SFM
|
||||||
|
response). Returns ``None`` for ``None`` input. Naive ISO strings
|
||||||
|
from SFM are interpreted as UTC.
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if isinstance(dt, str):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(dt.replace("Z", "").replace(" ", "T"))
|
||||||
|
except ValueError:
|
||||||
|
return dt # give up gracefully — emit whatever SFM sent
|
||||||
|
local = utc_to_local(dt)
|
||||||
|
if local is None:
|
||||||
|
return None
|
||||||
|
return local.replace(tzinfo=None).isoformat()
|
||||||
|
|
||||||
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
||||||
# clutter from a sub-second handoff during a swap workflow.
|
# clutter from a sub-second handoff during a swap workflow.
|
||||||
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
||||||
@@ -185,8 +211,8 @@ async def deployment_timeline_for_unit(
|
|||||||
overlays[a.id] = {
|
overlays[a.id] = {
|
||||||
"event_count": len(events),
|
"event_count": len(events),
|
||||||
"peak_pvs": peak,
|
"peak_pvs": peak,
|
||||||
"peak_pvs_at": peak_at,
|
"peak_pvs_at": _iso_local(peak_at),
|
||||||
"last_event": last_ev,
|
"last_event": _iso_local(last_ev),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||||
@@ -202,8 +228,8 @@ async def deployment_timeline_for_unit(
|
|||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"kind": "assignment",
|
"kind": "assignment",
|
||||||
"starts_at": _iso_utc(a.assigned_at),
|
"starts_at": _iso_local(a.assigned_at),
|
||||||
"ends_at": _iso_utc(a.assigned_until),
|
"ends_at": _iso_local(a.assigned_until),
|
||||||
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
||||||
"assignment_id": a.id,
|
"assignment_id": a.id,
|
||||||
"location_id": a.location_id,
|
"location_id": a.location_id,
|
||||||
@@ -227,8 +253,8 @@ async def deployment_timeline_for_unit(
|
|||||||
if gap_seconds >= _MIN_GAP_SECONDS:
|
if gap_seconds >= _MIN_GAP_SECONDS:
|
||||||
entries.append({
|
entries.append({
|
||||||
"kind": "gap",
|
"kind": "gap",
|
||||||
"starts_at": _iso_utc(gap_start),
|
"starts_at": _iso_local(gap_start),
|
||||||
"ends_at": _iso_utc(gap_end),
|
"ends_at": _iso_local(gap_end),
|
||||||
"duration_days": round(gap_seconds / 86400, 1),
|
"duration_days": round(gap_seconds / 86400, 1),
|
||||||
"context": "between assignments",
|
"context": "between assignments",
|
||||||
})
|
})
|
||||||
@@ -241,7 +267,7 @@ async def deployment_timeline_for_unit(
|
|||||||
continue
|
continue
|
||||||
entries.append({
|
entries.append({
|
||||||
"kind": "state_change",
|
"kind": "state_change",
|
||||||
"starts_at": _iso_utc(h.changed_at),
|
"starts_at": _iso_local(h.changed_at),
|
||||||
"ends_at": None,
|
"ends_at": None,
|
||||||
"duration_days": None,
|
"duration_days": None,
|
||||||
"change_type": h.change_type,
|
"change_type": h.change_type,
|
||||||
|
|||||||
+474
-10
@@ -28,6 +28,27 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const MODAL_ID = 'event-detail-modal';
|
const MODAL_ID = 'event-detail-modal';
|
||||||
|
|
||||||
|
// ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ──
|
||||||
|
const _CHANNEL_COLORS = {
|
||||||
|
MicL: '#e066ff', // purple — distinct from the geo channels
|
||||||
|
Long: '#3b82f6', // blue
|
||||||
|
Vert: '#22c55e', // green
|
||||||
|
Tran: '#ef4444', // red
|
||||||
|
};
|
||||||
|
const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
|
||||||
|
|
||||||
|
// dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit).
|
||||||
|
const DBL_REF = 2.9e-9;
|
||||||
|
// Mic display floor — sound-pressure AC samples sit at the digitisation
|
||||||
|
// noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without
|
||||||
|
// a floor, the chart looks like a sparse pattern of "moments when sound
|
||||||
|
// briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve.
|
||||||
|
const MIC_DBL_FLOOR = 60;
|
||||||
|
|
||||||
|
let _charts = {}; // ch → Chart instance
|
||||||
|
let _micUnitPref = 'psi'; // refreshed via fetch on first chart render
|
||||||
|
let _micUnitPrefLoaded = false; // one-shot fetch guard
|
||||||
|
|
||||||
function _esc(s) {
|
function _esc(s) {
|
||||||
if (s == null) return '';
|
if (s == null) return '';
|
||||||
return String(s).replace(/&/g, '&')
|
return String(s).replace(/&/g, '&')
|
||||||
@@ -224,32 +245,370 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _renderReview(s, eventId) {
|
||||||
|
const rev = s.review || {};
|
||||||
|
const ft = !!rev.false_trigger;
|
||||||
|
const reviewer = rev.reviewer || '';
|
||||||
|
const notes = rev.notes || '';
|
||||||
|
const reviewedAt = rev.reviewed_at
|
||||||
|
? rev.reviewed_at.replace('T', ' ').slice(0, 19)
|
||||||
|
: null;
|
||||||
|
return `<div class="bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" id="event-review-ft" ${ft ? 'checked' : ''}
|
||||||
|
class="w-4 h-4 rounded text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="font-medium">Flag as false trigger</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2 text-sm flex-1 min-w-[180px]">
|
||||||
|
<label for="event-review-reviewer" class="text-gray-500">Reviewer</label>
|
||||||
|
<input type="text" id="event-review-reviewer" value="${_esc(reviewer)}"
|
||||||
|
placeholder="Initials or name"
|
||||||
|
class="flex-1 px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="event-review-notes" class="block text-xs text-gray-500 mb-1">Notes</label>
|
||||||
|
<textarea id="event-review-notes" rows="2"
|
||||||
|
placeholder="Optional context — what caused the FT, follow-up actions, etc."
|
||||||
|
class="w-full px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">${_esc(notes)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 mt-3">
|
||||||
|
<span id="event-review-status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
|
||||||
|
</span>
|
||||||
|
<button type="button"
|
||||||
|
onclick="window.saveEventReview('${_esc(eventId)}')"
|
||||||
|
class="px-4 py-1.5 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Waveform / histogram chart helpers ──────────────────────────
|
||||||
|
|
||||||
|
async function _loadMicUnitPref() {
|
||||||
|
if (_micUnitPrefLoaded) return _micUnitPref;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings/preferences');
|
||||||
|
if (r.ok) {
|
||||||
|
const prefs = await r.json();
|
||||||
|
_micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Network error → silent fall back to default 'psi'.
|
||||||
|
}
|
||||||
|
_micUnitPrefLoaded = true;
|
||||||
|
return _micUnitPref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _psiToDbl(psi) {
|
||||||
|
if (psi == null || !(psi > 0)) return null;
|
||||||
|
return 20 * Math.log10(psi / DBL_REF);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rectifying psi→dBL converter for per-sample values — see comments in
|
||||||
|
// sfm_webapp.html:2592-2607 for the floor rationale.
|
||||||
|
function _psiToDblForChart(psi) {
|
||||||
|
if (psi == null) return MIC_DBL_FLOOR;
|
||||||
|
const a = Math.abs(psi);
|
||||||
|
if (a === 0) return MIC_DBL_FLOOR;
|
||||||
|
const dbl = 20 * Math.log10(a / DBL_REF);
|
||||||
|
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive decimal formatter — sensible precision in the normal range,
|
||||||
|
// scientific notation only at the extremes.
|
||||||
|
function _fmtPeak(v, unit) {
|
||||||
|
if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
|
||||||
|
if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
|
||||||
|
if (v === 0) return '0' + (unit ? ' ' + unit : '');
|
||||||
|
const a = Math.abs(v);
|
||||||
|
const u = unit ? ' ' + unit : '';
|
||||||
|
if (a >= 0.0001 && a < 10000) {
|
||||||
|
const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
|
||||||
|
return v.toFixed(d) + u;
|
||||||
|
}
|
||||||
|
return v.toExponential(2) + u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _destroyCharts() {
|
||||||
|
Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
|
||||||
|
_charts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true when Tailwind dark mode is active (the `dark` class is
|
||||||
|
// toggled on <html> by Terra-View's theme handler). Drives chart grid
|
||||||
|
// + tick colors so they have contrast on both backgrounds.
|
||||||
|
function _isDark() {
|
||||||
|
return document.documentElement.classList.contains('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderWaveformInto(containerId, data, micUnit) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
_destroyCharts();
|
||||||
|
|
||||||
|
const channels = data.channels || {};
|
||||||
|
const ta = data.time_axis || {};
|
||||||
|
const sr = ta.sample_rate || 1024;
|
||||||
|
const dtMs = ta.dt_ms || (1000.0 / sr);
|
||||||
|
const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
|
||||||
|
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
|
||||||
|
|
||||||
|
const withData = _CHANNEL_ORDER.filter(ch =>
|
||||||
|
channels[ch] && (channels[ch].values || []).length > 0
|
||||||
|
);
|
||||||
|
const lastCh = withData[withData.length - 1];
|
||||||
|
|
||||||
|
// Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
|
||||||
|
// light is white. Grids + ticks need contrast on both.
|
||||||
|
const dark = _isDark();
|
||||||
|
const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||||
|
const tickColor = dark ? '#94a3b8' : '#64748b';
|
||||||
|
|
||||||
|
if (withData.length === 0) {
|
||||||
|
container.innerHTML = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
|
||||||
|
No waveform samples decoded — codec walker returned 0 valid blocks for this event.
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ch of _CHANNEL_ORDER) {
|
||||||
|
const chData = channels[ch];
|
||||||
|
if (!chData) continue;
|
||||||
|
let values = chData.values || [];
|
||||||
|
let chUnit = chData.unit || '';
|
||||||
|
let chPeak = chData.peak;
|
||||||
|
|
||||||
|
// Mic: convert psi → dBL when the user pref is dBL (default).
|
||||||
|
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
|
||||||
|
values = values.map(_psiToDblForChart);
|
||||||
|
chPeak = _psiToDbl(chPeak);
|
||||||
|
chUnit = 'dB(L)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
|
||||||
|
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
|
||||||
|
lbl.style.color = _CHANNEL_COLORS[ch];
|
||||||
|
const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
|
||||||
|
lbl.innerHTML = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
|
||||||
|
wrap.appendChild(lbl);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
const e = document.createElement('div');
|
||||||
|
e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
|
||||||
|
e.textContent = 'no samples decoded';
|
||||||
|
wrap.appendChild(e);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasWrap = document.createElement('div');
|
||||||
|
canvasWrap.className = 'relative';
|
||||||
|
canvasWrap.style.height = '100px';
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvasWrap.appendChild(canvas);
|
||||||
|
wrap.appendChild(canvasWrap);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
|
||||||
|
// X-axis: waveforms use ms-relative-to-trigger; histograms use
|
||||||
|
// the BW-reported interval timestamps (HH:MM:SS) when the server
|
||||||
|
// aggregated to BW intervals, else interval index.
|
||||||
|
let times;
|
||||||
|
if (isHistogram) {
|
||||||
|
const intervalTimes = ta.interval_times || [];
|
||||||
|
times = (intervalTimes.length === values.length)
|
||||||
|
? intervalTimes
|
||||||
|
: values.map((_, i) => i + 1);
|
||||||
|
} else {
|
||||||
|
times = values.map((_, i) => t0Ms + i * dtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downsample for rendering when very long.
|
||||||
|
const MAX = 3000;
|
||||||
|
let rT = times, rV = values;
|
||||||
|
if (values.length > MAX) {
|
||||||
|
const step = Math.ceil(values.length / MAX);
|
||||||
|
rT = times.filter((_, i) => i % step === 0);
|
||||||
|
rV = values.filter((_, i) => i % step === 0);
|
||||||
|
}
|
||||||
|
const showX = (ch === lastCh);
|
||||||
|
|
||||||
|
const xAxisLabel = isHistogram ? '' : ' ms';
|
||||||
|
const fmtTick = i => {
|
||||||
|
const v = rT[i];
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
|
||||||
|
return s + xAxisLabel;
|
||||||
|
}
|
||||||
|
return String(v) + xAxisLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
|
||||||
|
let yBounds = {};
|
||||||
|
const isGeo = ch !== 'MicL';
|
||||||
|
if (isGeo && !isHistogram) {
|
||||||
|
let absMax = 0;
|
||||||
|
for (const v of values) {
|
||||||
|
const a = Math.abs(v);
|
||||||
|
if (a > absMax) absMax = a;
|
||||||
|
}
|
||||||
|
const padded = (absMax || 1) * 1.10;
|
||||||
|
yBounds = { min: -padded, max: padded };
|
||||||
|
} else if (isGeo && isHistogram) {
|
||||||
|
const HIST_GEO_MIN_INS = 0.05;
|
||||||
|
let peak = 0;
|
||||||
|
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
|
||||||
|
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
|
||||||
|
} else if (ch === 'MicL' && micUnit === 'dBL') {
|
||||||
|
const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
|
||||||
|
? chPeak + 5 : 100;
|
||||||
|
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
|
||||||
|
} else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
|
||||||
|
const HIST_MIC_MIN_PSI = 0.001;
|
||||||
|
let peak = 0;
|
||||||
|
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
|
||||||
|
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
|
||||||
|
}
|
||||||
|
|
||||||
|
_charts[ch] = new Chart(canvas, {
|
||||||
|
type: isHistogram ? 'bar' : 'line',
|
||||||
|
data: {
|
||||||
|
labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
|
||||||
|
datasets: isHistogram ? [{
|
||||||
|
data: rV,
|
||||||
|
backgroundColor: _CHANNEL_COLORS[ch],
|
||||||
|
borderWidth: 0,
|
||||||
|
barPercentage: 1.0,
|
||||||
|
categoryPercentage: 1.0,
|
||||||
|
}] : [{
|
||||||
|
data: rV,
|
||||||
|
borderColor: _CHANNEL_COLORS[ch],
|
||||||
|
borderWidth: 1,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: false, responsive: true, maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index', intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: items => isHistogram
|
||||||
|
? `interval ${items[0].label}`
|
||||||
|
: `t = ${items[0].label} ms`,
|
||||||
|
label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'category', display: showX,
|
||||||
|
ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
|
||||||
|
grid: { color: gridColor, drawTicks: showX },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
...yBounds,
|
||||||
|
ticks: { color: tickColor, maxTicksLimit: 4 },
|
||||||
|
grid: { color: gridColor },
|
||||||
|
title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: isHistogram ? [] : [{
|
||||||
|
id: 'overlays',
|
||||||
|
afterDraw(chart) {
|
||||||
|
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
|
||||||
|
const zi = rT.findIndex(t => parseFloat(t) >= 0);
|
||||||
|
if (zi >= 0) {
|
||||||
|
const px = x.getPixelForValue(zi);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
|
||||||
|
ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
|
||||||
|
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
const zy = y.getPixelForValue(0);
|
||||||
|
if (zy >= y.top && zy <= y.bottom) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
|
||||||
|
ctx.setLineDash([2, 2]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = tickColor; ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('0.0', x.right + 6, zy);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _renderFileInfo(s, eventId) {
|
function _renderFileInfo(s, eventId) {
|
||||||
const bw = s.blastware || {};
|
const bw = s.blastware || {};
|
||||||
const src = s.source || {};
|
const src = s.source || {};
|
||||||
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
||||||
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
|
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
|
||||||
|
const txtFilename = src && src.txt_filename;
|
||||||
|
const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`;
|
||||||
|
const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`;
|
||||||
|
|
||||||
const downloadButtons = `
|
const downloadButtons = `
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<button type="button"
|
||||||
|
onclick="window.toggleEventPdfPreview()"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="event-pdf-toggle-label">Show Event Report PDF</span>
|
||||||
|
</button>
|
||||||
|
<a href="${reportPdfUrl}" download
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download PDF
|
||||||
|
</a>
|
||||||
${canDownloadBinary ? `
|
${canDownloadBinary ? `
|
||||||
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
||||||
download="${_esc(bw.filename)}"
|
download="${_esc(bw.filename)}"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Download Blastware file
|
Blastware binary
|
||||||
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
|
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span>
|
||||||
</a>
|
</a>
|
||||||
` : `
|
` : ''}
|
||||||
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
|
${txtFilename ? `
|
||||||
|
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Blastware file unavailable
|
Original .TXT report
|
||||||
</span>
|
</a>
|
||||||
`}
|
` : ''}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="window.toggleEventJsonViewer()"
|
onclick="window.toggleEventJsonViewer()"
|
||||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||||
@@ -267,6 +626,11 @@
|
|||||||
Download sidecar JSON
|
Download sidecar JSON
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="event-pdf-preview" class="hidden mb-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-slate-900">
|
||||||
|
<iframe id="event-pdf-iframe" title="Event Report PDF preview"
|
||||||
|
class="w-full" style="height:80vh; min-height:600px; border:0;"
|
||||||
|
data-pdf-url="${reportPdfUrl}"></iframe>
|
||||||
|
</div>
|
||||||
<div id="event-json-viewer" class="hidden mb-4">
|
<div id="event-json-viewer" class="hidden mb-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
||||||
@@ -283,7 +647,9 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
||||||
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
||||||
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span></div>
|
<div title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that).">
|
||||||
|
<span class="text-gray-500">Time received</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span>
|
||||||
|
</div>
|
||||||
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -345,6 +711,10 @@
|
|||||||
${_sectionHeader('Peak Particle Velocity')}
|
${_sectionHeader('Peak Particle Velocity')}
|
||||||
${_renderPeakValues(s)}
|
${_renderPeakValues(s)}
|
||||||
|
|
||||||
|
${_sectionHeader('Waveform')}
|
||||||
|
<div id="event-waveform-status" class="text-xs text-gray-500 dark:text-gray-400 italic mb-2">Loading waveform…</div>
|
||||||
|
<div id="event-waveform-charts" class="space-y-0.5"></div>
|
||||||
|
|
||||||
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
||||||
${_sectionHeader('Microphone')}
|
${_sectionHeader('Microphone')}
|
||||||
${_renderMic(s)}
|
${_renderMic(s)}
|
||||||
@@ -358,14 +728,43 @@
|
|||||||
${_renderDeviceMetadata(s)}
|
${_renderDeviceMetadata(s)}
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
${_sectionHeader('Review')}
|
||||||
|
${_renderReview(s, eventId)}
|
||||||
|
|
||||||
${_sectionHeader('Source File')}
|
${_sectionHeader('Source File')}
|
||||||
${_renderFileInfo(s, eventId)}
|
${_renderFileInfo(s, eventId)}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Waveform load runs after the sidecar content is in the DOM, in
|
||||||
|
// parallel with the mic-unit-pref fetch. Either may complete first.
|
||||||
|
try {
|
||||||
|
const [wfRes, micUnit] = await Promise.all([
|
||||||
|
fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`),
|
||||||
|
_loadMicUnitPref(),
|
||||||
|
]);
|
||||||
|
if (wfRes.status === 404) {
|
||||||
|
document.getElementById('event-waveform-status').textContent =
|
||||||
|
'No waveform data — codec returned 0 valid blocks for this event.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wfRes.ok) {
|
||||||
|
document.getElementById('event-waveform-status').textContent =
|
||||||
|
'Failed to load waveform: HTTP ' + wfRes.status;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wfData = await wfRes.json();
|
||||||
|
document.getElementById('event-waveform-status').textContent = '';
|
||||||
|
_renderWaveformInto('event-waveform-charts', wfData, micUnit);
|
||||||
|
} catch (e) {
|
||||||
|
const st = document.getElementById('event-waveform-status');
|
||||||
|
if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.closeEventDetailModal = function () {
|
window.closeEventDetailModal = function () {
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
if (modal) modal.classList.add('hidden');
|
if (modal) modal.classList.add('hidden');
|
||||||
|
_destroyCharts();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.toggleEventJsonViewer = function () {
|
window.toggleEventJsonViewer = function () {
|
||||||
@@ -376,6 +775,71 @@
|
|||||||
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.toggleEventPdfPreview = function () {
|
||||||
|
const preview = document.getElementById('event-pdf-preview');
|
||||||
|
const iframe = document.getElementById('event-pdf-iframe');
|
||||||
|
const label = document.getElementById('event-pdf-toggle-label');
|
||||||
|
if (!preview || !iframe) return;
|
||||||
|
const isHidden = preview.classList.toggle('hidden');
|
||||||
|
// Lazy-load the PDF: only set the iframe src on first reveal, so
|
||||||
|
// closing the event modal without opening the PDF never spends
|
||||||
|
// bandwidth on it.
|
||||||
|
if (!isHidden && !iframe.src) {
|
||||||
|
iframe.src = iframe.dataset.pdfUrl || '';
|
||||||
|
}
|
||||||
|
if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF';
|
||||||
|
// Scroll the iframe into view on first reveal so the operator
|
||||||
|
// doesn't have to hunt for it after clicking.
|
||||||
|
if (!isHidden) {
|
||||||
|
preview.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.saveEventReview = async function (eventId) {
|
||||||
|
const ft = document.getElementById('event-review-ft');
|
||||||
|
const reviewer = document.getElementById('event-review-reviewer');
|
||||||
|
const notes = document.getElementById('event-review-notes');
|
||||||
|
const status = document.getElementById('event-review-status');
|
||||||
|
if (!ft || !reviewer || !notes) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
review: {
|
||||||
|
false_trigger: ft.checked,
|
||||||
|
reviewer: reviewer.value.trim() || null,
|
||||||
|
notes: notes.value.trim() || null,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Saving…';
|
||||||
|
status.className = 'text-xs text-gray-500 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const t = await r.text().catch(() => '');
|
||||||
|
throw new Error('HTTP ' + r.status + (t ? ` — ${t.slice(0, 120)}` : ''));
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Saved.';
|
||||||
|
status.className = 'text-xs text-green-600 dark:text-green-400';
|
||||||
|
}
|
||||||
|
// Notify the host page so its event-list FT badge / row state
|
||||||
|
// can refresh. Pages opt in by listening for this event.
|
||||||
|
window.dispatchEvent(new CustomEvent('sfm-event-review-saved', {
|
||||||
|
detail: { eventId, review: payload.review },
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Save failed: ' + e.message;
|
||||||
|
status.className = 'text-xs text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.copyEventJson = function () {
|
window.copyEventJson = function () {
|
||||||
const pre = document.getElementById('event-json-pre');
|
const pre = document.getElementById('event-json-pre');
|
||||||
const label = document.getElementById('event-json-copy-label');
|
const label = document.getElementById('event-json-copy-label');
|
||||||
|
|||||||
+11
-2
@@ -1,18 +1,27 @@
|
|||||||
/* Service Worker for Seismo Fleet Manager PWA */
|
/* Service Worker for Seismo Fleet Manager PWA */
|
||||||
/* Network-first strategy with cache fallback for real-time data */
|
/* Network-first strategy with cache fallback for real-time data */
|
||||||
|
|
||||||
const CACHE_VERSION = 'v1';
|
// IMPORTANT: bump this on every release that touches a precached or
|
||||||
|
// runtime-cached static asset (event-modal.js, mobile.js, style.css,
|
||||||
|
// templates served at /, etc.). The activate handler deletes any cache
|
||||||
|
// not matching CACHE_VERSION, so old SW caches get evicted and mobile
|
||||||
|
// PWA users actually receive the new bundles instead of being stuck on
|
||||||
|
// the pre-bump version. Convention: keep it in sync with the Terra-View
|
||||||
|
// version string in backend/main.py.
|
||||||
|
const CACHE_VERSION = 'v0.13.2';
|
||||||
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||||
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||||
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||||
|
|
||||||
// Files to precache (critical app shell)
|
// Files to precache (critical app shell). event-modal.js is included
|
||||||
|
// so its cache lifecycle is tied to the SW version bump explicitly.
|
||||||
const STATIC_FILES = [
|
const STATIC_FILES = [
|
||||||
'/',
|
'/',
|
||||||
'/static/style.css',
|
'/static/style.css',
|
||||||
'/static/mobile.css',
|
'/static/mobile.css',
|
||||||
'/static/mobile.js',
|
'/static/mobile.js',
|
||||||
'/static/offline-db.js',
|
'/static/offline-db.js',
|
||||||
|
'/static/event-modal.js',
|
||||||
'/static/manifest.json',
|
'/static/manifest.json',
|
||||||
'https://cdn.tailwindcss.com',
|
'https://cdn.tailwindcss.com',
|
||||||
'https://unpkg.com/htmx.org@1.9.10',
|
'https://unpkg.com/htmx.org@1.9.10',
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ services:
|
|||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||||
- SFM_BASE_URL=http://sfm:8200
|
- SFM_BASE_URL=http://sfm:8200
|
||||||
|
# Display timezone for server logs + any text-rendered timestamps.
|
||||||
|
# DB columns are stored UTC regardless; this only affects what
|
||||||
|
# operators see. Override here for non-US-East deployments.
|
||||||
|
- TZ=America/New_York
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
@@ -56,9 +60,18 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../seismo-relay/sfm/data:/app/sfm/data
|
- ../seismo-relay/sfm/data:/app/sfm/data
|
||||||
- ../seismo-relay/bridges/captures:/app/bridges/captures
|
- ../seismo-relay/bridges/captures:/app/bridges/captures
|
||||||
|
# The DB + waveform store inside bridges/captures are symlinks
|
||||||
|
# pointing at the prod-snap directory. Mount its host path at
|
||||||
|
# the same absolute path inside the container so the symlinks
|
||||||
|
# resolve. Needed for SFM to query the events DB.
|
||||||
|
- ../seismo-relay-prod-snap:/home/serversdown/seismo-relay-prod-snap
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8200
|
- PORT=8200
|
||||||
|
# Display timezone — affects server log timestamps, the PDF
|
||||||
|
# report renderer's UTC→local conversions, and matplotlib's
|
||||||
|
# datetime axes. DB columns (created_at etc.) are always UTC.
|
||||||
|
- TZ=America/New_York
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||||
|
|||||||
@@ -192,8 +192,9 @@ 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">
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
|
||||||
<td class="px-3 py-2"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
|
onclick="showEventDetail('${_esc(ev.id)}')">
|
||||||
|
<td class="px-3 py-2" onclick="event.stopPropagation()"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
|
||||||
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
|
<td class="px-3 py-2 text-sm 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>
|
||||||
@@ -355,5 +356,15 @@ 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 %}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ Usage:
|
|||||||
#}
|
#}
|
||||||
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
|
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
|
||||||
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
|
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
|
||||||
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
|
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-5xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[92vh] overflow-y-auto">
|
||||||
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
|
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 z-10">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
||||||
<button onclick="closeEventDetailModal()"
|
<button onclick="closeEventDetailModal()"
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||||
@@ -23,3 +23,7 @@ Usage:
|
|||||||
<div id="event-detail-modal-content"></div>
|
<div id="event-detail-modal-content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{# Chart.js — pinned to v4.4.1 to match the SFM webapp's reference impl
|
||||||
|
(v4 chart API; differs from v3). Loaded once globally; safe if other
|
||||||
|
pages on the same template tree also load it. #}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
|||||||
+21
-1
@@ -122,6 +122,21 @@
|
|||||||
How often the dashboard should refresh automatically
|
How often the dashboard should refresh automatically
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Event-Report Mic Units -->
|
||||||
|
<div>
|
||||||
|
<label for="mic-unit-pref" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Event Report — Mic Channel Units
|
||||||
|
</label>
|
||||||
|
<select id="mic-unit-pref"
|
||||||
|
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="psi" selected>psi — raw pressure (matches PDF report)</option>
|
||||||
|
<option value="dBL">dB(L) — sound pressure level</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
@@ -771,6 +786,9 @@ async function loadPreferences() {
|
|||||||
// Load auto-refresh interval
|
// Load auto-refresh interval
|
||||||
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
|
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
|
||||||
|
|
||||||
|
// Load event-report mic units
|
||||||
|
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'psi';
|
||||||
|
|
||||||
// Load status thresholds
|
// Load status thresholds
|
||||||
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
|
||||||
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
|
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
|
||||||
@@ -788,6 +806,7 @@ async function saveGeneralSettings() {
|
|||||||
const timezone = document.getElementById('timezone-select').value;
|
const timezone = document.getElementById('timezone-select').value;
|
||||||
const theme = document.querySelector('input[name="theme"]:checked').value;
|
const theme = document.querySelector('input[name="theme"]:checked').value;
|
||||||
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
|
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
|
||||||
|
const micUnitPref = document.getElementById('mic-unit-pref').value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/settings/preferences', {
|
const response = await fetch('/api/settings/preferences', {
|
||||||
@@ -796,7 +815,8 @@ async function saveGeneralSettings() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
timezone,
|
timezone,
|
||||||
theme,
|
theme,
|
||||||
auto_refresh_interval: autoRefreshInterval
|
auto_refresh_interval: autoRefreshInterval,
|
||||||
|
mic_unit_pref: micUnitPref
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,13 @@
|
|||||||
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<script src="/static/event-modal.js"></script>
|
||||||
|
<script>
|
||||||
|
// Refresh the events table when the modal's review form saves —
|
||||||
|
// keeps the FT badge in sync without a full page reload.
|
||||||
|
window.addEventListener('sfm-event-review-saved', () => {
|
||||||
|
if (typeof loadEvents === 'function') loadEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sfm-tab {
|
.sfm-tab {
|
||||||
|
|||||||
@@ -3720,5 +3720,11 @@ function showToast(message, type = 'info') {
|
|||||||
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<script src="/static/event-modal.js"></script>
|
||||||
|
<script>
|
||||||
|
// Refresh the unit's events table when the modal's review form saves.
|
||||||
|
window.addEventListener('sfm-event-review-saved', () => {
|
||||||
|
if (typeof loadUnitEvents === 'function') loadUnitEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -992,4 +992,10 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
|||||||
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
||||||
{% include 'partials/event_detail_modal.html' %}
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<script src="/static/event-modal.js"></script>
|
<script src="/static/event-modal.js"></script>
|
||||||
|
<script>
|
||||||
|
// Refresh the location's events table when the modal's review form saves.
|
||||||
|
window.addEventListener('sfm-event-review-saved', () => {
|
||||||
|
if (typeof loadLocationEvents === 'function') loadLocationEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user