Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff438c1197 | |||
| 16eb9eb1fe | |||
| 991aaca34b |
@@ -1,5 +1,3 @@
|
|||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Python cache / compiled
|
# Python cache / compiled
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -30,7 +28,6 @@ ENV/
|
|||||||
|
|
||||||
# Runtime data (mounted volumes)
|
# Runtime data (mounted volumes)
|
||||||
data/
|
data/
|
||||||
data-dev/
|
|
||||||
|
|
||||||
# Editors / OS junk
|
# Editors / OS junk
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# Terra-View deployment configuration — EXAMPLE / template.
|
|
||||||
#
|
|
||||||
# Copy this to `.env` in the same directory as docker-compose.yml and fill in
|
|
||||||
# real values: cp .env.example .env
|
|
||||||
# `.env` is gitignored — NEVER commit real secrets. Docker Compose auto-loads
|
|
||||||
# `.env` and substitutes these into the ${VAR} placeholders in docker-compose.yml.
|
|
||||||
|
|
||||||
# Cookie-signing secret shared by the client portal AND the operator-auth
|
|
||||||
# session cookie. MUST be a strong random value in production — the in-code
|
|
||||||
# fallback ("dev-insecure-change-me") is public and forgeable.
|
|
||||||
# Generate one (and keep it secret):
|
|
||||||
# python3 -c "import secrets; print(secrets.token_urlsafe(48))"
|
|
||||||
SECRET_KEY=change-me-generate-a-strong-random-value
|
|
||||||
|
|
||||||
# Set true ONLY when the app is served over HTTPS. On plain HTTP leave it false,
|
|
||||||
# or the browser won't send the session cookie and login will look broken.
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
|
|
||||||
# Operator-auth login gate. Leave false to deploy "dark" (the app behaves exactly
|
|
||||||
# as before — nothing gated, nothing can lock you out). Roll out by: deploy with
|
|
||||||
# false -> seed a superadmin via `docker compose exec web-app python3
|
|
||||||
# backend/operator_admin.py create-superadmin ...` -> confirm you can log in ->
|
|
||||||
# set true and `docker compose up -d web-app` to enforce. Setting it back to
|
|
||||||
# false is the instant escape hatch.
|
|
||||||
OPERATOR_AUTH_ENABLED=false
|
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
# Terra-View Specifics
|
|
||||||
# Dev build counter (local only, never commit)
|
|
||||||
build_number.txt
|
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# SQLite database files
|
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
data/
|
|
||||||
data-dev/
|
|
||||||
.aider*
|
|
||||||
.aider*
|
|
||||||
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
@@ -224,6 +210,4 @@ __marimo__/
|
|||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
/data/
|
data/
|
||||||
/data-dev/
|
|
||||||
.aider*
|
|
||||||
|
|||||||
@@ -1,876 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Terra-View will be documented in this file.
|
All notable changes to Seismo Fleet Manager 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]
|
|
||||||
|
|
||||||
## [0.16.0] - 2026-06-23
|
|
||||||
|
|
||||||
**Modular projects & live Overview** — the project page becomes a module-aware workspace. Sound and vibration are now first-class *modules* with independent lifecycles (finish the sound study while vibration keeps running), the internal Overview gains the live-monitoring treatment the client portal already had, and vibration events become browsable in-app. Rounds out a batch of project-page UX work plus deployment-hopper and filter fixes carried since 0.15.0.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Per-module status (independent lifecycle).** Each project module (Sound / Vibration) now carries its own status — `active` / `on_hold` / `completed` — separate from the parent project. Mark the sound side "Completed" while vibration stays "Active" instead of archiving the whole project. Set from a status control in each module's tab; surfaced as a badge on the header module chips and on the project cards. New `PUT /api/projects/{id}/modules/{type}/status`; new `project_modules.status` column.
|
|
||||||
- **Live monitoring on the internal project Overview.** The internal project page gets the live treatment the client portal already had: a live-monitoring section with per-NRL tiles (current level + status), live/offline counts, a "loudest now" readout, and auto-refresh — reading SLMM's shared cached feed, no extra device hits. Live status chips on the NRL list cards; tiles are clickable through to the NRL detail page. Shown only for projects with live-mode (connected) sound NRLs.
|
|
||||||
- **Vibration events, browsable in-app.** A project-wide **Events** sub-tab on the Vibration tab lists SFM events across every vibration location, with a **Location** filter and **sortable columns** (timestamp, location, serial, Tran/Vert/Long/PVS/Mic). Each Vibration location card shows its **last event**. Rows open the shared event-detail modal.
|
|
||||||
- **24-Hour session period type.** A full-day (day + night) period type for 24/7 jobs, alongside the existing weekday/weekend day/night types; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime.
|
|
||||||
- **Per-module project cards + quick-open.** Redesigned project cards: a module-mix accent strip, per-module stat lines (e.g. "Vibration · 4 locations · 2 units" / "Sound · 2 NRLs · 2 units · 0 recording"), and **Sound / Vibration quick-open buttons** that deep-link straight into that module's tab.
|
|
||||||
- **"Reforward info" button** on classified deployment-hopper cards — re-syncs the captured photo's GPS/metadata onto the location (recovery path for the coords-drop bug fixed below).
|
|
||||||
- **`redeploy.sh`** helper — rebuild + redeploy a single compose service with `--no-deps` instead of rebuilding the whole stack.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Project page restructured around modules.** Sound-only actions (Generate Combined Report, Night Report, Report Settings) and the Manual/Remote chip moved out of the global project header into the **Sound tab's** toolbar; the header now carries project-level concerns only (status, modules, merge). The Overview's locations are split into **Vibration locations** and **NRLs** instead of one mixed list.
|
|
||||||
- **Project cards: clearer stats.** The ambiguous single Locations / Units / **Active** row (where "Active" collided with the status badge and only counted sound recording sessions) is replaced by per-module stat lines; the misleading single-module subtitle ("Sound Monitoring") is replaced by the project's identity (number · client). Unit counts are derived by the assigned location's type, so each module's unit count reconciles with its location count.
|
|
||||||
- **Overview live-monitoring scope.** The live section lists only connected / live-mode NRLs; offline / manual-upload NRLs are excluded, and the section is hidden entirely when no NRL is in a live mode.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **SLM "Start measurement" showed a false "Unknown error"** even though the unit was actually starting — the proxy timed out on a slow handler and surfaced an empty error. (Pairs with the SLMM-side `'Start'`-state fix in SLMM v0.4.0.)
|
|
||||||
- **Empty project dropdown** in the pending-deployment classify modal — the projects endpoint returned HTML while the modal expected JSON; now backed by a JSON endpoint.
|
|
||||||
- **Classify button stuck on "Classifying…"** after reopening the modal post-deploy.
|
|
||||||
- **Deployment-capture GPS dropped** when assigning to an *existing* location — the captured coordinates now backfill onto a coordless existing location (and the new Reforward button re-syncs after the fact).
|
|
||||||
- **Event date filters were unusable** on the unit / vibration-location detail pages (datetime-local inputs with no apply) — replaced with date inputs that apply on change.
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
|
|
||||||
- **Migration:** run `backend/migrate_add_module_status.py` once against the production DB (adds `project_modules.status`, default `active`) — e.g. `docker compose exec web-app python3 backend/migrate_add_module_status.py`. Without it, the per-module status endpoints/UI error.
|
|
||||||
- **Ships with SLMM v0.4.0.** The Overview live monitoring reads SLMM's shared `/monitor` fan-out feed, and the SLM start fix pairs with SLMM's `'Start'`-state recognition — deploy the matching SLMM v0.4.0 build, not one without the other.
|
|
||||||
|
|
||||||
## [0.15.0] - 2026-06-18
|
|
||||||
|
|
||||||
**Operator authentication** — the internal app gets a login. The operator-facing surface had **zero auth**; this adds a deny-by-default login gate and roles, the prerequisite that makes the app safe to put behind a public URL (office-deployment sequencing: auth → expose). Built test-first (10 tasks, 90 passing tests — the project's first auth test suite alongside the portal's).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.** The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints `/emitters/report` `/api/series3/heartbeat` `/api/series4/heartbeat`). Two roles — `superadmin` (account management at `/admin/users`) and `admin` (full app); `operator` reserved/deferred. Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`). Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI. Brute-force lockout (5 tries / 15 min) + constant-time login (no user-enumeration). Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` HMAC signer. Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`, plan: `docs/superpowers/plans/2026-06-17-operator-auth.md`.
|
|
||||||
- **`.env.example`** documenting `SECRET_KEY` / `COOKIE_SECURE` / `OPERATOR_AUTH_ENABLED` for deployment.
|
|
||||||
|
|
||||||
### Known limitations
|
|
||||||
|
|
||||||
- **SLMM proxy WebSocket endpoints bypass the gate.** `/api/slmm/{id}/stream|live|monitor` are WebSocket upgrades, which a Starlette HTTP middleware never sees — they stay unauthenticated even with the gate on. Pre-existing (not a regression); close it (in-handler `tv_session` check, as the portal WS already does) before true internet exposure.
|
|
||||||
- **No TLS yet** — until served over HTTPS the login password crosses the wire in cleartext. Still a large improvement over the prior zero-auth exposure; real internet exposure needs the deployment-phase TLS.
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
|
|
||||||
- New `operator_users` table **auto-creates on startup — no migration**.
|
|
||||||
- Set a real `SECRET_KEY` (in a gitignored `.env`; template at `.env.example`) before internet exposure; set `COOKIE_SECURE=true` once on HTTPS (leave `false` on plain HTTP or the browser won't send the cookie).
|
|
||||||
- **Rollout (no self-lockout):** deploy with `OPERATOR_AUTH_ENABLED=false` (app behaves exactly as before) → seed a superadmin via `docker compose exec web-app python3 backend/operator_admin.py create-superadmin …` → confirm you can log in → set the flag `true` and `docker compose up -d web-app`. Flipping it back to `false` is the instant escape hatch.
|
|
||||||
|
|
||||||
## [0.14.0] - 2026-06-17
|
|
||||||
|
|
||||||
Rounds out **sound monitoring** and adds a **client-facing portal**, consolidating four threads since 0.13.x: SLM live monitoring (now on SLMM's shared, cached feed), an automated **FTP night-report pipeline**, a read-only **client portal**, and **per-project password auth** for it. Depends on the matching **SLMM `dev`** build — see Upgrade Notes at the end of each section.
|
|
||||||
|
|
||||||
### SLM live monitoring — fan-out feed + cache-first
|
|
||||||
|
|
||||||
SLM live monitoring — fan-out feed + cache-first reads. 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### FTP night-report pipeline *(new)*
|
|
||||||
|
|
||||||
Automated daily morning report of last night's noise (7 PM–7 AM) vs a baseline,
|
|
||||||
per location, for 24/7 remote sound jobs. The meter records to its SD card
|
|
||||||
regardless of TCP state, so the report pulls the meter's own stored 15-minute
|
|
||||||
Leq intervals over FTP (via the SLMM proxy) — accurate, and resilient to a
|
|
||||||
control-path wedge. **Field-tested on a real NL-43.**
|
|
||||||
|
|
||||||
#### Added
|
|
||||||
|
|
||||||
- **Report engine.** Per-location LAmax / LA01 / LA10 / LA90 / LAeq over Evening
|
|
||||||
(7–10 PM) + Nighttime (10 PM–7 AM); Leq energy-averaged, percentiles/Lmax
|
|
||||||
arithmetic; the LN→percentile map is read from the device's own `.rnh`. Two
|
|
||||||
baseline modes: *captured* (weekly average) and *reference* (typed per-location
|
|
||||||
limits).
|
|
||||||
- **Renderers.** HTML email body + Excel attachment (per-NRL interval table +
|
|
||||||
line chart + Last/Base/Δ summary).
|
|
||||||
- **Capture cycle.** The daily scheduled "24/7 Continuous" cycle stops →
|
|
||||||
downloads → ingests → re-indexes → restarts the meter, verifies it resumed
|
|
||||||
measuring via a fresh DOD read, and retries the restart once before alerting.
|
|
||||||
- **Standardized ingest.** Manual SD upload, manual FTP "Download & Save", and
|
|
||||||
the scheduled cycle all funnel through one ingest core: keeps the `.rnh` +
|
|
||||||
15-minute Leq, drops the 1-second `_Lp_` files, parses the header, dedupes, and
|
|
||||||
derives the session's real recording window from the Leq rows.
|
|
||||||
- **UI.** Night Report button/modal (view / run-and-email / recent reports) and a
|
|
||||||
per-project Settings panel (enable, time, baseline, recipients, test-email); the
|
|
||||||
per-NRL Data Files tab now matches the project-wide tab.
|
|
||||||
- **Config-driven SMTP** sender (`REPORT_SMTP_*`), dry-run when unconfigured.
|
|
||||||
|
|
||||||
#### Fixed
|
|
||||||
|
|
||||||
- **NL-43 sessions stamped `now` / zero-duration.** The NL-43 `.rnh` carries no
|
|
||||||
measurement timestamps, so the session window is now derived from the Leq rows.
|
|
||||||
Also fixes NL-43 dedupe (it had keyed on an always-empty start time).
|
|
||||||
- **"Browse Files" did nothing on the NRL Data Files tab** — the FTP-browser
|
|
||||||
script's global functions collided with the SLM live-view's (both loaded on that
|
|
||||||
page); it's now namespaced behind `window.FtpBrowser`.
|
|
||||||
|
|
||||||
#### Upgrade Notes
|
|
||||||
|
|
||||||
- **No DB migration** — the `sound_report_configs` table auto-creates on startup.
|
|
||||||
- Set `REPORT_SMTP_HOST/PORT/SECURITY/USER/PASSWORD/FROM/RECIPIENTS` to send email
|
|
||||||
(reports build to `data/reports/…` in dry-run until then).
|
|
||||||
- To automate a job: a **"24/7 Continuous"** recurring schedule (~7:15 AM) + enable
|
|
||||||
the report (~8:00 AM) + set a baseline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Client portal *(new — read-only client-facing view)*
|
|
||||||
|
|
||||||
A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
|
|
||||||
locations, live. Built inside Terra-View (no new service), reusing the cached
|
|
||||||
SLMM feed; every route resolves the client through one swappable
|
|
||||||
`get_current_client` gate, so the interim magic/open-link auth can be replaced
|
|
||||||
(M4) without touching routes or templates. Strictly read-only — no device control.
|
|
||||||
|
|
||||||
#### Added
|
|
||||||
|
|
||||||
- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a
|
|
||||||
`Project.client_id` FK. A signed (HMAC) session cookie carries the access-token
|
|
||||||
id, re-validated against the DB each request (revoke kills live sessions, with
|
|
||||||
server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a
|
|
||||||
dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
|
|
||||||
- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
|
|
||||||
instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
|
|
||||||
the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream
|
|
||||||
**auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
|
|
||||||
cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
|
|
||||||
- **Locations overview.** Live status map (level-colored dots, dark/light CARTO
|
|
||||||
tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the
|
|
||||||
headline metric.
|
|
||||||
- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail
|
|
||||||
page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
|
|
||||||
read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
|
|
||||||
in the portal; enabling a rule pins that device's monitor on so alerts evaluate
|
|
||||||
round-the-clock.
|
|
||||||
- **Operator sharing tools.** A **"View client portal"** preview button and a
|
|
||||||
**"Copy client link"** modal (mint / list / revoke magic links) on the project
|
|
||||||
page, plus a `backend/portal_admin.py` CLI.
|
|
||||||
- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI +
|
|
||||||
IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
|
|
||||||
**light/dark toggle** (light default, persisted, no-flash).
|
|
||||||
|
|
||||||
#### Security
|
|
||||||
|
|
||||||
- All scoping enforced server-side (404-not-403, no existence leak); client
|
|
||||||
endpoints return **scrubbed** projections (no device-health/internal ids); WS
|
|
||||||
frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
|
|
||||||
Pre-merge code review hardened cookie expiry, open-links default, and the slug
|
|
||||||
collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
|
|
||||||
tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".
|
|
||||||
|
|
||||||
#### Upgrade Notes
|
|
||||||
|
|
||||||
- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
|
|
||||||
(adds `projects.client_id`; the `clients` / `client_access_tokens` tables
|
|
||||||
auto-create).
|
|
||||||
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
|
|
||||||
and keep **`PORTAL_OPEN_LINKS=false`** there.
|
|
||||||
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
|
|
||||||
cooldown + keepalive coupling) — same build pairing as above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Portal authentication (Phase 1)
|
|
||||||
- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link).
|
|
||||||
- Per-project session isolation (a session for one project can't read another's data); brute-force lockout (5 tries / 15 min) on the password gate.
|
|
||||||
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links and the `portal_admin.py mint-link` command.
|
|
||||||
- **Upgrade:** new `argon2-cffi` dependency → **rebuild the image**, then run `python3 backend/migrate_add_project_portal_auth.py` per DB (adds the `projects.portal_*` columns). `SECRET_KEY` and `COOKIE_SECURE` are now passed through in `docker-compose.yml` (settable via a `.env` file) — set a real `SECRET_KEY` (and `COOKIE_SECURE=true` once on HTTPS) before the portal faces the internet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.13.3] - 2026-06-05
|
|
||||||
|
|
||||||
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Calibration sync service** (`backend/services/calibration_sync.py`). Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`. Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
|
|
||||||
- **Conflict rule: events-as-truth, manual wins when newer.** Three outcomes per unit:
|
|
||||||
- `already_in_sync` — stored date already matches the event's calibration date.
|
|
||||||
- `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved. Only a future event can supersede it.
|
|
||||||
- `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
|
|
||||||
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`). Started in `main.py`'s startup hook, stopped on shutdown. Does not run on boot — first sync after a server start fires at the next 03:15.
|
|
||||||
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`. Powers the Settings button.
|
|
||||||
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows. Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
|
|
||||||
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab). Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
|
|
||||||
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist. Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**. The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it. Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
|
|
||||||
No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist. Rebuild only:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/terra-view
|
|
||||||
docker compose build terra-view && docker compose up -d terra-view
|
|
||||||
```
|
|
||||||
|
|
||||||
After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.13.2] - 2026-05-30
|
|
||||||
|
|
||||||
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
|
|
||||||
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
|
|
||||||
|
|
||||||
### What mobile users will see after deploy
|
|
||||||
|
|
||||||
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.13.1] - 2026-05-29
|
|
||||||
|
|
||||||
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
|
|
||||||
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
|
|
||||||
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/terra-view
|
|
||||||
docker compose build terra-view && docker compose up -d terra-view
|
|
||||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
|
||||||
```
|
|
||||||
|
|
||||||
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.13.0] - 2026-05-29
|
|
||||||
|
|
||||||
The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
|
|
||||||
|
|
||||||
### Added — Event-detail modal: Chart.js waveform/histogram panels
|
|
||||||
|
|
||||||
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
|
|
||||||
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
|
|
||||||
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
|
|
||||||
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
|
|
||||||
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
|
|
||||||
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
|
|
||||||
|
|
||||||
### Added — Event-detail modal: PDF preview + downloads + review form
|
|
||||||
|
|
||||||
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
|
|
||||||
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
|
|
||||||
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
|
|
||||||
|
|
||||||
### Added — `/admin/events` row click opens the modal
|
|
||||||
|
|
||||||
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
|
|
||||||
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
|
|
||||||
|
|
||||||
### Added — `mic_unit_pref` user setting (Settings → General)
|
|
||||||
|
|
||||||
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
|
|
||||||
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
|
|
||||||
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
|
|
||||||
|
|
||||||
### Fixed — Docker Compose: SFM container can finally read the DB
|
|
||||||
|
|
||||||
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/terra-view
|
|
||||||
# Apply the new column to the database — required. Idempotent.
|
|
||||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
|
||||||
|
|
||||||
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
|
|
||||||
docker compose build terra-view && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
|
|
||||||
|
|
||||||
### What's NOT in this release
|
|
||||||
|
|
||||||
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.12.1] - 2026-05-20
|
|
||||||
|
|
||||||
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
|
|
||||||
|
|
||||||
### Added — Unit Swap wizard (`/tools/unit-swap`)
|
|
||||||
|
|
||||||
- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm. Designed for tap-driven use on a phone in the field; works on desktop too.
|
|
||||||
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf. Each row carries a `deployed` boolean for badge rendering. Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
|
|
||||||
- **`POST /locations/{loc}/swap` enhancements**:
|
|
||||||
- Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` flag consistent with the active-assignment signal.
|
|
||||||
- Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), the stale back-reference is broken before re-pairing.
|
|
||||||
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
|
|
||||||
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.
|
|
||||||
|
|
||||||
### Added — Editable deployment timeline on `/unit/{id}`
|
|
||||||
|
|
||||||
- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline. Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes. Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
|
|
||||||
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment. Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
|
|
||||||
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows. Closed historical assignments that don't overlap an existing one are accepted (the backfill case).
|
|
||||||
- After any save/delete the timeline reloads and the SFM-events list re-fetches, so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`). The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever. All three now: close the previous active assignment, break the outgoing unit's modem pairing (both directions), and set `deployed = False` on the outgoing unit. Unassign and swap also clear the modem's back-reference. Promote-pending additionally handles the case where the target location already has an active assignment — previously this silently created two active assignments at the same location; now the old one is closed (`assigned_until = pending.capture_time`, `status = completed`), the old unit benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
|
|
||||||
- **Deployment timeline now respects user timezone for display *and* edits.** Timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen. Two-sided fix:
|
|
||||||
- **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing. Frontend slicing keeps working — it just slices a local-time string now.
|
|
||||||
- **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`. Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
|
|
||||||
No schema changes. Static code-only release — pull and restart:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/terra-view
|
|
||||||
docker compose build terra-view && docker compose up -d terra-view
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.12.0] - 2026-05-17
|
|
||||||
|
|
||||||
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
|
|
||||||
|
|
||||||
### Added — field-deployment workflow
|
|
||||||
|
|
||||||
- **`/deploy` — mobile-first 3-step capture wizard**: pick unit → take photo (opens phone camera via `<input capture="environment">`) → optional note → submit. Designed for under-90-seconds-on-site. Success page shows captured coords and links back to "Deploy another" or the pending hopper.
|
|
||||||
- **`/tools/pending-deployments` — the hopper**: filter pills Awaiting / Assigned / Cancelled. Each card has photo thumbnail, unit link, coords, operator note, status-appropriate actions.
|
|
||||||
- **Classify modal**: two modes — assign to existing project+location, OR create new location with new-or-existing project + a "use captured coords" checkbox that writes the pending row's coords onto the new location record.
|
|
||||||
- **`PendingDeployment` data model** (`pending_deployments` table): lifecycle `awaiting → assigned | cancelled`. Photo file lives under `data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_<uuid8>.<ext>`. Migration: `backend/migrate_add_pending_deployments.py` (idempotent).
|
|
||||||
- **Backend endpoints**:
|
|
||||||
- `POST /api/deployments/capture` — multipart upload (unit_id, photo, optional note), EXIF GPS extraction, seismograph-only (rejects others with 400)
|
|
||||||
- `GET /api/deployments/pending` — list by status
|
|
||||||
- `GET /api/deployments/pending/{id}` — single row detail
|
|
||||||
- `POST /api/deployments/pending/{id}/promote` — classify and create `UnitAssignment`; events in the assignment window get retroactively attributed via the existing metadata-backfill mechanism
|
|
||||||
- `POST /api/deployments/pending/{id}/cancel` — abandon with optional reason
|
|
||||||
- `GET /api/deployments/seismograph-picker` — JSON list for the `/deploy` picker, annotated with `has_pending`
|
|
||||||
- **Discovery surfaces**: orange "Field Deploy" button on the desktop dashboard header (md+), bottom-nav slot 3 on mobile (Menu / Dashboard / Deploy / Events; Devices moved into the Menu drawer), `/tools` cards for both Field Deploy and Pending Deployments, dashboard banner that auto-shows when awaiting captures exist (polled every 30s, hides at 0).
|
|
||||||
- **Full audit trail**: every capture / promote / cancel writes a `UnitHistory` row (`pending_deployment_captured` / `_promoted` / `_cancelled`).
|
|
||||||
|
|
||||||
### Added — fleet-wide deployment history
|
|
||||||
|
|
||||||
- **`/tools/deployment-history` — fleet-wide 12-month calendar** (Phase 2 of the per-unit Gantt from v0.11.0). 4-month-per-row grid styled like the Job Planner, responsive to single column on mobile. Each day cell shows up to 4 deterministically-colored mini-bars (one per active project that day), with "+N" overflow. KPI strip across the top: project count, distinct unit count, total assignment count in the window. Collapsible project legend ordered by first-active date.
|
|
||||||
- **Click-a-day side panel**: slide-over from the right, groups by project, lists every (unit, location) active that day with auto-backfilled tags, sourced from new `GET /api/admin/deployment-history/day`.
|
|
||||||
- **Prev / Next / Recent month navigation**: shifts the 12-month window by 1 month. Default window is 11 months back from current → operator sees recent past on first load, not future emptiness.
|
|
||||||
- **Gantt by Project tab**: horizontal time-axis bars per project, hover for tooltip with unit + location + window. Reduced opacity for closed assignments, blue outline for metadata-backfilled, today dashed-orange line.
|
|
||||||
- **Gantt by Unit tab**: same idea inverted — one row per seismograph, bars colored by project. Natural for "where has BE11529 been across all my jobs?" Service layer returns a `units` array with bars carrying baked-in `project_color`.
|
|
||||||
- **Tab switcher with hash sync**: `#gantt` / `#byunit` preserved across month-paging. Tab registry (`_DH_TABS`) makes adding future views a one-line addition.
|
|
||||||
|
|
||||||
### Added — SFM event DB management
|
|
||||||
|
|
||||||
- **`/admin/events` — SFM Event DB Manager** under Developer Tools. Cross-unit event browser with filters (serial, from/to, false_trigger, limit), checkbox selection with select-all, and bulk actions:
|
|
||||||
- **Delete selected** — hard-delete chosen rows from SFM's `events` table
|
|
||||||
- **Delete ALL matching current filter** — dry-run first to show match count + sample serials in confirm dialog; only proceeds on explicit confirmation
|
|
||||||
- Same Flag-as-FT / Clear-FT bulk actions for convenience
|
|
||||||
- **Destructive operations also clean up on-disk files**: associated `.AB0*` blastware binary, `.a5.pkl`, `.sfm.json` sidecar, and `.h5` files are unlinked alongside the DB row. Cannot be undone — the manager has a prominent red warning banner and a `max_rows` safety cap (10,000) that refuses oversized deletes without explicit acknowledgment.
|
|
||||||
- **Designed for cleaning bogus events from a misbehaving unit** — a stuck-triggered seismograph can dump hundreds of junk events into SFM before it's recovered; this is the operator's broom.
|
|
||||||
- **`unit_detail.html` also gains bulk false-trigger flagging**: same checkbox UX as the DB manager, but with **🚩 Flag as false trigger** / **✓ Clear false trigger** instead of delete (delete is admin-only via `/admin/events`). Concurrent fan-out (8 in flight) for fast bulk PATCH.
|
|
||||||
|
|
||||||
### Added — maps, navigation, polish
|
|
||||||
|
|
||||||
- **Reusable location-map partial** (`templates/partials/projects/location_map.html`): self-contained map div + self-fetch script. Accepts `project_id`, `map_height`, `location_type` filter. Project overview's inline map (~150 lines of JS) replaced with a 1-line include; Vibration tab on the project detail page now uses the same partial with `location_type='vibration'` at 450px height.
|
|
||||||
- **Hover location card → highlight matching map pin** on the project overview map. Enlarges + reddens the pin, opens its tooltip. Bidirectional with the existing pin → card flash. Event delegation on `document` so cards from htmx swaps keep the behavior without rewiring.
|
|
||||||
- **Mobile bottom-nav swap Settings → Events**: Settings (rarely needed in the field) replaced by Events (the daily mobile destination since the SFM integration). Settings/Projects/Tools/admin pages still in the Menu drawer.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **`/deploy` photo input now allows gallery picks**: `capture="environment"` was forcing mobile browsers to open the camera and skip "Photo Library" / "Choose File". Useful at the install site, problematic when uploading a photo taken earlier. Attribute removed; chooser now offers both options. EXIF extraction works identically.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
|
|
||||||
One new migration this release. Idempotent and non-destructive.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Or sweep all migrations at once (safe — already-applied ones no-op):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
for f in backend/migrate_*.py; do
|
|
||||||
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
New table: `pending_deployments` — capture rows for the field-deployment workflow. Empty after migration; populated as field techs use `/deploy`.
|
|
||||||
|
|
||||||
**Deploy order matters**: run the migration BEFORE the new code is up, or the running app will 500 on the missing table. Same gotcha that bit the v0.10.0 → v0.11.0 deploy.
|
|
||||||
|
|
||||||
**SFM bump pairing**: this release pairs with seismo-relay v0.17.0, which adds the `DELETE /db/events/{id}` and `POST /db/events/delete_bulk` endpoints that `/admin/events` consumes. An older SFM will return 405/404 for those routes; the manager will surface the error in its result alert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.11.0] - 2026-05-15
|
|
||||||
|
|
||||||
Operator-facing polish release. All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events. Cascade-closes active unit assignments and cancels pending scheduled actions at the location. Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming). Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
|
|
||||||
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page. Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups. Click a bar to scroll the matching list row into view with a flash highlight.
|
|
||||||
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record). Now auto-detected and surfaceable in the timeline header — one click combines them into a single record. Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
|
|
||||||
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment. Trash icon in each row of the location's Deployment History panel. Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history. Writes an `assignment_deleted` UnitHistory row.
|
|
||||||
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left. Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`. Implementation uses native HTML5 drag-and-drop (no library). New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
|
|
||||||
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu. Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
|
|
||||||
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations still show session counts.
|
|
||||||
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map. One pin per active monitoring location (parsed from the `coordinates` field). Click pin → scrolls + flashes the matching card. Tooltip on hover. Locations without coordinates surface as an inline hint below the map. If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores. Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio. Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens. Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90. Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler. Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
|
|
||||||
- **Location Remove button** on the project page
|
|
||||||
- **Metadata-backfill typeahead dropdown** (existing project + location pickers)
|
|
||||||
- **Project-merge typeahead dropdown** (in the per-project header)
|
|
||||||
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
|
|
||||||
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`. Fixed by adding `isolation: isolate` to the map container.
|
|
||||||
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`. Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run on each database before deploying. Both migrations are idempotent and non-destructive.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
|
|
||||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Or sweep all migrations at once (safe — already-applied ones no-op):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
for f in backend/migrate_*.py; do
|
|
||||||
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
New columns added this release:
|
|
||||||
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
|
|
||||||
- `monitoring_locations.removal_reason` (TEXT, nullable)
|
|
||||||
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration
|
|
||||||
|
|
||||||
**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns. Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.10.0] - 2026-05-14
|
|
||||||
|
|
||||||
This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **SFM Integration**: New fleet-wide events page at `/sfm` listing every event ingested by SFM, with filters for serial, date range, false-trigger flag, and limit. Unit detail pages and project-location pages show their own attributed subsets of the same event stream.
|
|
||||||
- **Event Detail Modal**: Shared across `/sfm`, unit detail, and project-location pages — clicking any event opens a rich modal showing peaks per channel (PVS color-coded by magnitude), microphone dB(L) + ZC frequency + time of peak, sensor self-check table with pass/fail per channel, device/recording metadata (firmware, battery, calibration date, geo range), and download buttons for the original Blastware binary and the sidecar JSON. Includes an inline pretty-printed JSON viewer with copy-to-clipboard.
|
|
||||||
- **Events Attribution Engine** (`backend/services/sfm_events.py`): Per-event attribution against `UnitAssignment` time windows. Events outside any assignment window surface in an "Unattributed" bucket with the nearest-assignment diagnostic (which location, signed delta in days).
|
|
||||||
- **Metadata Backfill Tool** (`/tools` → Backfill from event metadata): Scans operator-typed `project` and `sensor_location` strings in event sidecars, fuzzy-clusters them via `rapidfuzz.WRatio`, and proposes retroactive `UnitAssignment` records to attribute orphan events. Tracks operator decisions per cluster across re-scans.
|
|
||||||
- **Project Tidy Tool** (`/tools` → Project Tidy): Fuzzy-detect duplicate projects and bulk-merge them with a single click. Source projects soft-deleted with full audit trail.
|
|
||||||
- **Vibration Summary on Project Pages**: New roll-up card on vibration project detail pages showing per-location event counts, the project's "Overall Peak" PVS (false triggers excluded), last event timestamp, and a Top Locations by Activity list.
|
|
||||||
- **SFM-Primary Seismograph Status**: `emit_status_snapshot()` now consults SFM's `/db/units` (cached 15s) before falling back to `Emitter.last_seen` for each seismograph. The fresher signal wins; the choice is recorded in a new per-unit `last_seen_source` field. A small `SFM` (orange) or `HB` (gray) badge on each unit's active-table row shows which path is currently driving the status.
|
|
||||||
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moved to a horizontal collapsible card below the Fleet Map, auto-expanding only when pending actions exist. Recent Call-Ins now sources from a new `/api/recent-event-callins` endpoint backed by SFM event forwards instead of the watcher-heartbeat endpoint.
|
|
||||||
- **Sortable Events Tables**: `/sfm` and unit-detail SFM Events tables now have clickable column headers with ↕/↓/↑ indicators. Default sort is Timestamp DESC. Click same column to toggle direction; click different column to switch and reset to DESC. Pure client-side over cached rows — no re-fetches.
|
|
||||||
- **Developer → SFM Admin** (`/admin/sfm`): Health banner with reachability indicator, terra-view↔SFM connection panel, 4 KPI tiles (known units, total events, stale `monitor_log` rows, stale `ach_sessions` rows), per-unit roll-up table, recent-events table with color-coded forwarding latency (so stale watcher forwards stand out), and a raw API tester for any `/api/sfm/*` path.
|
|
||||||
- **Developer → SLMM Admin** (`/admin/slmm`): Stripped-down companion page — health, connection info, raw API tester.
|
|
||||||
- **Tools Workflow Hub** (`/tools`): New top-level sidebar entry consolidating Pair Devices, Project Tidy, Metadata Backfill, Reports (info card), and Swap Detection (placeholder).
|
|
||||||
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices) replacing five separate sidebar items.
|
|
||||||
- **Synology Deployment Doc** (`docs/SYNOLOGY_DEPLOYMENT.md`): End-to-end playbook for migrating the stack to an always-on office NAS — phased rollout (pre-stage, data rsync, watcher repoint, external access, decommission), Tailscale vs reverse-proxy options, rollback plan, and gotchas.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Overall Peak excludes false triggers**: The project-level "Overall Peak" KPI tile (and the underlying `_compute_stats()` function in `sfm_events.py`) now skip events flagged as false triggers when computing the highest PVS, so operators see the highest real event rather than the biggest sensor glitch. `false_trigger_count` still includes flagged events so operators can see how many were filtered out.
|
|
||||||
- **`RosterUnit.note` Editing**: Inline edit on seismograph cards is more forgiving and now auto-saves on blur.
|
|
||||||
- **Sidebar Nav Renamed**: Old "Fleet" sidebar entry → "Devices" (renamed because it always meant the device list, not the broader fleet view).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Status drift between watcher heartbeat and actual event arrivals**: Seismographs are now reported with whichever signal is more recent — eliminates the case where a unit had recent SFM events but a stale heartbeat (or vice-versa) showed the wrong status.
|
|
||||||
- **Event modal: Record Type always showed "Waveform"**: Workaround client-side — Record Type now derived from the Blastware filename's last-char code (`H`=Histogram, `W`=Waveform, `M`=Manual, `E`=Event, `C`=Combo). The proper fix lives in SFM's sidecar parser; tracked separately.
|
|
||||||
- **Event modal: Mic PSI tile removed**: Operators only care about dB(L); the redundant PSI tile was dropped.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run on each database before deploying. Every migration is idempotent.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Cleanest: re-run all migrations in chronological order.
|
|
||||||
# Already-applied migrations no-op safely.
|
|
||||||
for f in backend/migrate_*.py; do
|
|
||||||
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
Migrations new in this release:
|
|
||||||
- `migrate_add_metadata_backfill.py` — adds `unit_assignments.source` column and `metadata_backfill_decisions` table for the Metadata Backfill tool
|
|
||||||
|
|
||||||
### Deployment Notes
|
|
||||||
- **`SFM_BASE_URL`**: Confirm prod's `docker-compose.yml` sets this for the terra-view service (typically `http://sfm:8200` for the in-stack SFM container, or an external URL if SFM lives elsewhere).
|
|
||||||
- **Watcher repoint**: series3-watcher's `sfm_forward_url` should point at `https://<your-terra-view-host>/api/sfm` (proxy-based — no second port forward needed). Watcher composes the full path `/db/import/blastware_file` itself.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.9.4] - 2026-04-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Modular Project Types**: Projects now support optional modules (Sound Monitoring, Vibration Monitoring) selectable at creation time. The project header and dashboard dynamically show/hide tabs and actions based on which modules are enabled, and modules can be added or removed after creation.
|
|
||||||
- **Deleted Project Management**: Settings page now includes a section for soft-deleted projects with options to restore or permanently delete each one. Deleted projects load automatically when the Data tab is opened.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Swap Modal Search**: The unit/modem swap modal on vibration location detail pages now includes live search filtering for both seismographs and modems, making it easier to find the right unit in large fleets.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Roster Auto-Refresh No Longer Disrupts Scroll/Sort**: The roster page's 30-second background refresh now updates status, age, and last-seen values in-place via a lightweight JSON poll instead of replacing the entire table HTML. Sort order, scroll position, and active filters are all preserved across refreshes.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run on each database before deploying:
|
|
||||||
```bash
|
|
||||||
docker compose exec terra-view python3 backend/migrate_add_project_modules.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.9.3] - 2026-03-28
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
|
|
||||||
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
|
|
||||||
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
|
|
||||||
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
|
|
||||||
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
|
|
||||||
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
|
|
||||||
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
|
|
||||||
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run on each database before deploying:
|
|
||||||
```bash
|
|
||||||
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
|
|
||||||
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.9.2] - 2026-03-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
|
|
||||||
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
|
|
||||||
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
|
|
||||||
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
|
|
||||||
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
|
|
||||||
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run on each database before deploying:
|
|
||||||
```bash
|
|
||||||
docker compose exec terra-view python3 backend/migrate_add_allocated.py
|
|
||||||
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.9.1] - 2026-03-20
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
|
|
||||||
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
|
|
||||||
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run on each database before deploying:
|
|
||||||
```bash
|
|
||||||
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.9.0] - 2026-03-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
|
|
||||||
- **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
|
|
||||||
- **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
|
|
||||||
- **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
|
|
||||||
- **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
|
|
||||||
- **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
|
|
||||||
- **Available Units panel**: Shows units available for the job's date range when assigning
|
|
||||||
- **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
|
|
||||||
- **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
|
|
||||||
- **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
|
|
||||||
- **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
|
|
||||||
- **Calendar bar tooltips**: Hover any job bar to see job name and date range
|
|
||||||
- **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
|
|
||||||
- **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
|
|
||||||
- **Upcoming project status**: New `upcoming` status for projects promoted from reservations
|
|
||||||
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
|
|
||||||
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
|
|
||||||
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
|
|
||||||
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
|
|
||||||
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
|
|
||||||
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
|
|
||||||
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run on each database before deploying:
|
|
||||||
```bash
|
|
||||||
docker compose exec terra-view python3 -c "
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect('/app/data/seismo_fleet.db')
|
|
||||||
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.8.0] - 2026-03-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Watcher Manager**: New admin page (`/admin/watchers`) for monitoring field watcher agents
|
|
||||||
- Live status cards per agent showing connectivity, version, IP, last-seen age, and log tail
|
|
||||||
- Trigger Update button to queue a self-update on the agent's next heartbeat
|
|
||||||
- Expand/collapse log tail with full-log expand mode
|
|
||||||
- Live surgical refresh every 30 seconds via `/api/admin/watchers` — no full page reload, open logs stay open
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Watcher status logic**: Agent status now reflects whether Terra-View is hearing from the watcher (ok if seen within 60 minutes, missing otherwise) — previously reflected the worst unit status from the last heartbeat payload, which caused false alarms when units went missing
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Watcher Manager meta row**: Dark mode background was white due to invalid `dark:bg-slate-850` Tailwind class; corrected to `dark:bg-slate-800`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.7.1] - 2026-03-12
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
|
|
||||||
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Migration Scripts**: Fixed database path references in several migration scripts
|
|
||||||
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run the following migration script once per database before deploying:
|
|
||||||
```bash
|
|
||||||
python backend/migrate_add_out_for_calibration.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.7.0] - 2026-03-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
|
|
||||||
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
|
|
||||||
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
|
|
||||||
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
|
|
||||||
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
|
|
||||||
- Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
|
|
||||||
- Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
|
|
||||||
- Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
|
|
||||||
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
|
|
||||||
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
|
|
||||||
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
|
|
||||||
- Wizard UI grouped by location with period type badges (day/night)
|
|
||||||
- Each selected session produces one `.xlsx` in a ZIP archive
|
|
||||||
- Period type filtering: day sessions keep last calendar date (7AM–6:59PM); night sessions span both days (7PM–6:59AM)
|
|
||||||
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
|
|
||||||
- **Chart Preview**: Live chart preview in the report generator matching final report styling
|
|
||||||
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
|
|
||||||
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
|
|
||||||
- Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
|
|
||||||
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
|
|
||||||
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
|
|
||||||
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
|
|
||||||
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
|
|
||||||
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
|
|
||||||
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
|
|
||||||
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
|
|
||||||
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
|
|
||||||
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
|
|
||||||
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
|
|
||||||
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
|
|
||||||
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
Run the following migration scripts once per database before deploying:
|
|
||||||
```bash
|
|
||||||
python backend/migrate_rename_recording_to_monitoring_sessions.py
|
|
||||||
python backend/migrate_add_session_period_type.py
|
|
||||||
python backend/migrate_add_session_device_model.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.6.1] - 2026-02-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **One-Off Recording Schedules**: Support for scheduling single recordings with specific start and end datetimes
|
|
||||||
- **Bidirectional Pairing Sync**: Pairing a device with a modem now automatically updates both sides, clearing stale pairings when reassigned
|
|
||||||
- **Auto-Fill Notes from Modem**: Notes are now copied from modem to paired device when fields are empty
|
|
||||||
- **SLMM Download Requests**: New `_download_request` method in SLMM client for binary file downloads with local save
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Scheduler Timezone**: One-off scheduler times now use local time instead of UTC
|
|
||||||
- **Pairing Consistency**: Old device references are properly cleared when a modem is re-paired to a new device
|
|
||||||
|
|
||||||
## [0.6.0] - 2026-02-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Calendar & Reservation Mode**: Fleet calendar view with reservation system for scheduling device deployments
|
|
||||||
- **Device Pairing Interface**: New two-column pairing page (`/pair-devices`) for linking recorders (seismographs/SLMs) with modems
|
|
||||||
- Visual pairing interface with drag-and-drop style interactions
|
|
||||||
- Fuzzy-search modem pairing for SLMs
|
|
||||||
- Pairing options now accessible from modem page
|
|
||||||
- Improved pair status sharing across views
|
|
||||||
- **Modem Dashboard Enhancements**:
|
|
||||||
- Modem model number now a dedicated configuration field with per-model options
|
|
||||||
- Direct link to modem login page from unit detail view
|
|
||||||
- Modem view converted to list format
|
|
||||||
- **Seismograph List Improvements**:
|
|
||||||
- Enhanced visibility with better filtering and sorting
|
|
||||||
- Calibration dates now color-coded for quick status assessment
|
|
||||||
- User sets date of previous calibration (not expiry) for clearer workflow
|
|
||||||
- **SLMM Device Control Lock**: Prevents command flooding to NL-43 devices
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Calibration Date UX**: Users now set the date of the previous calibration rather than upcoming expiry dates - more intuitive workflow
|
|
||||||
- **Settings Persistence**: Settings save no longer reloads the page
|
|
||||||
- **Tab State**: Tab state now persists in URL hash for better navigation
|
|
||||||
- **Scheduler Management**: Schedule changes now cascade to individual events
|
|
||||||
- **Dashboard Filtering**: Enhanced dashboard with additional filtering options and SLM status sync
|
|
||||||
- **SLMM Polling Intervals**: Fixed and improved polling intervals for better responsiveness
|
|
||||||
- **24-Hour Scheduler Cycle**: Improved cycle handling to prevent issues with scheduled downloads
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **SLM Modal Fields**: Modal now only contains correct device-specific fields
|
|
||||||
- **IP Address Handling**: IP address correctly passed via modem pairing
|
|
||||||
- **Mobile Type Display**: Fixed incorrect device type display in roster and device tables
|
|
||||||
- **SLMM Scheduled Downloads**: Fixed issues with scheduled download operations
|
|
||||||
|
|
||||||
## [0.5.1] - 2026-01-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
|
|
||||||
- New "Today's Actions" panel showing upcoming and past scheduled events
|
|
||||||
- Schedule list partial for project-specific schedule views
|
|
||||||
- API endpoint for fetching today's schedule data
|
|
||||||
- **New Branding Assets**: Complete logo rework for Terra-View
|
|
||||||
- New Terra-View logos for light and dark themes
|
|
||||||
- Retina-ready (@2x) logo variants
|
|
||||||
- Updated favicons (16px and 32px)
|
|
||||||
- Refreshed PWA icons (72px through 512px)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Dashboard Layout**: Reorganized to include schedule information panel
|
|
||||||
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
|
|
||||||
|
|
||||||
## [0.5.0] - 2026-01-23
|
|
||||||
|
|
||||||
_Note: This version was not formally released; changes were included in v0.5.1._
|
|
||||||
|
|
||||||
## [0.4.4] - 2026-01-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Recurring schedules**: New scheduler service, recurring schedule APIs, and schedule templates (calendar/interval/list).
|
|
||||||
- **Alerts UI + backend**: Alerting service plus dropdown/list templates for surfacing notifications.
|
|
||||||
- **Report templates + viewers**: CRUD API for report templates, report preview screen, and RND file viewer.
|
|
||||||
- **SLM tooling**: SLM settings modal and SLM project report generator workflow.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Project data management**: Unified files view, refreshed FTP browser, and new project header/templates for file/session/unit/assignment lists.
|
|
||||||
- **Device/SLM sync**: Standardized SLM device types and tightened SLMM sync paths.
|
|
||||||
- **Docs/scripts**: Cleanup pass and expanded device-type documentation.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Scheduler actions**: Strict command definitions so actions run reliably.
|
|
||||||
- **Project view title**: Resolved JSON string rendering in project headers.
|
|
||||||
|
|
||||||
## [0.4.3] - 2026-01-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Sound Level Meter roster tooling**: Roster manager surfaces SLM metadata, supports rename unit flows, and adds return-to-project navigation to keep SLM dashboard users oriented.
|
|
||||||
- **Project management templates**: New schedule and unit list templates plus file/session lists show what each project stores before teams dive into deployments.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Project view refresh**: FTP browser now downloads folders locally, the countdown timer was rebuilt, and project/device templates gained edit modals for projects and locations so navigation feels smoother.
|
|
||||||
- **SLM control sync & accuracy**: Control center groundwork now runs inside the dev UI, configuration edits propagate to SLMM (which caches configs for faster responses), and the SLM live view reads the correct DRD fields after the refactor.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **SLM UI syntax bug**: Resolved the unexpected token error that appeared in the refreshed SLM components.
|
|
||||||
|
|
||||||
## [0.4.2] - 2026-01-05
|
## [0.4.2] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1214,12 +348,6 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
|
|
||||||
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
|
|
||||||
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
|
||||||
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
|
||||||
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
|
|
||||||
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
|
||||||
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
||||||
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
||||||
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Build number for dev builds (injected via --build-arg)
|
|
||||||
ARG BUILD_NUMBER=0
|
|
||||||
ENV BUILD_NUMBER=${BUILD_NUMBER}
|
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends iputils-ping curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose SFM port
|
||||||
|
EXPOSE 8002
|
||||||
|
|
||||||
|
# Run SFM backend (API only)
|
||||||
|
# For now: runs same app on different port
|
||||||
|
# Future: will run SFM-specific entry point
|
||||||
|
CMD ["python3", "-m", "app.main"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app /app/app
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Run the SLM application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends iputils-ping curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose Terra-View UI port
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# Run Terra-View (UI + orchestration)
|
||||||
|
CMD ["python3", "-m", "app.main"]
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Terra-View Modular Monolith - Known-Good Baseline
|
||||||
|
|
||||||
|
**Date:** 2026-01-09
|
||||||
|
**Status:** ✅ IMPORT MIGRATION COMPLETE
|
||||||
|
|
||||||
|
## What We've Achieved
|
||||||
|
|
||||||
|
Successfully restructured the application into a modular monolith architecture with the new folder structure working end-to-end.
|
||||||
|
|
||||||
|
## New Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/serversdown/sfm/seismo-fleet-manager/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # NEW: Entry point with Terra-View branding
|
||||||
|
│ ├── core/ # Shared infrastructure
|
||||||
|
│ │ ├── config.py # NEW: Centralized configuration
|
||||||
|
│ │ └── database.py # Shared DB utilities
|
||||||
|
│ ├── ui/ # UI Layer (device-agnostic)
|
||||||
|
│ │ ├── routes.py # NEW: HTML page routes
|
||||||
|
│ │ ├── templates/ # All HTML templates (copied from old location)
|
||||||
|
│ │ └── static/ # All static files (copied from old location)
|
||||||
|
│ ├── seismo/ # Seismograph Feature Module
|
||||||
|
│ │ ├── models.py # ✅ Updated to use app.seismo.database
|
||||||
|
│ │ ├── database.py # NEW: Seismo-specific DB connection
|
||||||
|
│ │ ├── routers/ # API routers (copied from backend/routers/)
|
||||||
|
│ │ └── services/ # Business logic (copied from backend/services/)
|
||||||
|
│ ├── slm/ # Sound Level Meter Feature Module
|
||||||
|
│ │ ├── models.py # NEW: Placeholder for SLM models
|
||||||
|
│ │ ├── database.py # NEW: SLM-specific DB connection
|
||||||
|
│ │ └── routers/ # SLM routers (copied from backend/routers/)
|
||||||
|
│ └── api/ # API Aggregation Layer (placeholder)
|
||||||
|
│ ├── dashboard.py # NEW: Future aggregation endpoints
|
||||||
|
│ └── roster.py # NEW: Future aggregation endpoints
|
||||||
|
└── data/
|
||||||
|
└── seismo_fleet.db # Still using shared DB (migration pending)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Working
|
||||||
|
|
||||||
|
✅ **Application starts successfully** on port 9999
|
||||||
|
✅ **Health endpoint works**: `/health` returns Terra-View v1.0.0
|
||||||
|
✅ **UI renders**: Main dashboard loads with proper templates
|
||||||
|
✅ **API endpoints work**: `/api/status-snapshot` returns seismograph data
|
||||||
|
✅ **Database access works**: Models properly connected
|
||||||
|
✅ **Static files serve**: CSS, JS, icons all accessible
|
||||||
|
|
||||||
|
## Critical Changes Made
|
||||||
|
|
||||||
|
### 1. Fixed Import in models.py
|
||||||
|
**File:** `app/seismo/models.py`
|
||||||
|
**Change:** `from backend.database import Base` → `from app.seismo.database import Base`
|
||||||
|
**Reason:** Avoid duplicate Base instances causing SQLAlchemy errors
|
||||||
|
|
||||||
|
### 2. Created New Entry Point
|
||||||
|
**File:** `app/main.py`
|
||||||
|
**Features:**
|
||||||
|
- Terra-View branding (title, version, health check)
|
||||||
|
- Imports from new `app.*` structure
|
||||||
|
- Registers all seismo and SLM routers
|
||||||
|
- Middleware for environment context
|
||||||
|
|
||||||
|
### 3. Created UI Routes Module
|
||||||
|
**File:** `app/ui/routes.py`
|
||||||
|
**Purpose:** Centralize all HTML page routes (device-agnostic)
|
||||||
|
|
||||||
|
### 4. Created Module-Specific Databases
|
||||||
|
**Files:** `app/seismo/database.py`, `app/slm/database.py`
|
||||||
|
**Status:** Both currently point to shared `seismo_fleet.db` (migration pending)
|
||||||
|
|
||||||
|
## Recent Updates (2026-01-09)
|
||||||
|
|
||||||
|
✅ **ALL imports updated** - Changed all `backend.*` imports to `app.seismo.*` or `app.slm.*`
|
||||||
|
✅ **Old structure deleted** - `backend/` and `templates/` directories removed
|
||||||
|
✅ **Containers rebuilt** - All three containers (Terra-View, SFM, SLMM) working with new imports
|
||||||
|
✅ **Verified working** - Tested health endpoints and UI after migration
|
||||||
|
|
||||||
|
## What's NOT Yet Done
|
||||||
|
|
||||||
|
❌ **Partial routes missing** - `/partials/*` endpoints not yet added
|
||||||
|
❌ **Database not split** - Still using shared `seismo_fleet.db`
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start on custom port to avoid conflicts
|
||||||
|
PORT=9999 python3 -m app.main
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
curl http://localhost:9999/health
|
||||||
|
|
||||||
|
# Test API endpoint
|
||||||
|
curl http://localhost:9999/api/status-snapshot
|
||||||
|
|
||||||
|
# Access UI
|
||||||
|
open http://localhost:9999/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps (Recommended Order)
|
||||||
|
|
||||||
|
1. **Add partial routes** to app/main.py or create separate router
|
||||||
|
2. **Test all endpoints thoroughly** - Verify roster CRUD, photos, settings
|
||||||
|
3. **Split databases** (Phase 2 of plan)
|
||||||
|
4. **Implement API aggregation layer** (Phase 3 of plan)
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None currently - app starts and serves requests successfully!
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] App starts without errors
|
||||||
|
- [x] Health endpoint returns correct version
|
||||||
|
- [x] Main dashboard loads
|
||||||
|
- [x] Status snapshot API works
|
||||||
|
- [ ] All seismo endpoints work
|
||||||
|
- [ ] All SLM endpoints work
|
||||||
|
- [ ] Roster CRUD operations work
|
||||||
|
- [ ] Photos upload/download works
|
||||||
|
- [ ] Settings page works
|
||||||
|
|
||||||
|
## Rollback Instructions
|
||||||
|
|
||||||
|
~~The old structure has been deleted.~~ To rollback, restore from your backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore from your backup
|
||||||
|
# The old backend/ and templates/ directories were removed on 2026-01-09
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **MIGRATION COMPLETE**: Old `backend/` and `templates/` directories removed
|
||||||
|
- **ALL IMPORTS UPDATED**: All Python files now use `app.*` imports
|
||||||
|
- **NO DATA LOSS**: Database untouched, only code structure changed
|
||||||
|
- **CONTAINERS WORKING**: All three containers (Terra-View, SFM, SLMM) healthy
|
||||||
|
- **FULLY SELF-CONTAINED**: Application runs entirely from `app/` directory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Congratulations!** 🎉 Import migration complete! The modular monolith is now self-contained and production-ready.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Terra-View v0.16.0
|
# Seismo Fleet Manager v0.4.2
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs, sound level meters, 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,23 +9,15 @@ 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
|
||||||
- **Sound Level Meter Monitoring**: Live per-device monitoring through SLMM's shared, cached feed — multiple viewers without contending for the NL-43's single connection — with L1/L10 percentile lines, a measuring/freshness indicator, and on-demand refresh
|
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
||||||
- **Automated Night Reports**: Daily per-location noise report (last night vs a baseline) for 24/7 remote sound jobs — pulls the meter's 15-minute Leq over FTP and emails an HTML summary + Excel; the meter is auto-cycled (stop → download → ingest → restart, with restart verification) each morning
|
|
||||||
- **Client Portal** (`/portal/*`): scoped, read-only, client-facing live view of *their* locations only, gated by a per-project link + shared password (argon2-hashed)
|
|
||||||
- **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 (reusable location-map partial across project overview + Vibration tab)
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
||||||
- **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
|
||||||
@@ -316,7 +308,7 @@ print(response.json())
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| id | string | Unit identifier (primary key) |
|
| id | string | Unit identifier (primary key) |
|
||||||
| unit_type | string | Hardware model name (default: `series3`) |
|
| unit_type | string | Hardware model name (default: `series3`) |
|
||||||
| device_type | string | Device type: `"seismograph"`, `"modem"`, or `"slm"` (sound level meter) |
|
| device_type | string | `seismograph` or `modem` discriminator |
|
||||||
| deployed | boolean | Whether the unit is in the field |
|
| deployed | boolean | Whether the unit is in the field |
|
||||||
| retired | boolean | Removes the unit from deployments but preserves history |
|
| retired | boolean | Removes the unit from deployments but preserves history |
|
||||||
| note | string | Notes about the unit |
|
| note | string | Notes about the unit |
|
||||||
@@ -342,39 +334,6 @@ print(response.json())
|
|||||||
| phone_number | string | Cellular number for the modem |
|
| phone_number | string | Cellular number for the modem |
|
||||||
| hardware_model | string | Modem hardware reference |
|
| hardware_model | string | Modem hardware reference |
|
||||||
|
|
||||||
**Sound Level Meter (SLM) fields**
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| slm_host | string | Direct IP address for SLM (if not using modem) |
|
|
||||||
| slm_tcp_port | integer | TCP control port (default: 2255) |
|
|
||||||
| slm_ftp_port | integer | FTP file transfer port (default: 21) |
|
|
||||||
| slm_model | string | Device model (NL-43, NL-53) |
|
|
||||||
| slm_serial_number | string | Manufacturer serial number |
|
|
||||||
| slm_frequency_weighting | string | Frequency weighting setting (A, C, Z) |
|
|
||||||
| slm_time_weighting | string | Time weighting setting (F=Fast, S=Slow) |
|
|
||||||
| slm_measurement_range | string | Measurement range setting |
|
|
||||||
| slm_last_check | datetime | Last status check timestamp |
|
|
||||||
| deployed_with_modem_id | string | Modem pairing (shared with seismographs) |
|
|
||||||
|
|
||||||
### Device Type Schema
|
|
||||||
|
|
||||||
Terra-View supports three device types with the following standardized `device_type` values:
|
|
||||||
|
|
||||||
- **`"seismograph"`** (default) - Seismic monitoring devices (Series 3, Series 4, Micromate)
|
|
||||||
- Uses: calibration dates, modem pairing
|
|
||||||
- Examples: BE1234, UM12345 (Series 3/4 units)
|
|
||||||
|
|
||||||
- **`"modem"`** - Field modems and network equipment
|
|
||||||
- Uses: IP address, phone number, hardware model
|
|
||||||
- Examples: MDM001, MODEM-2025-01
|
|
||||||
|
|
||||||
- **`"slm"`** - Sound level meters (Rion NL-43/NL-53)
|
|
||||||
- Uses: TCP/FTP configuration, measurement settings, modem pairing
|
|
||||||
- Examples: SLM-43-01, NL43-001
|
|
||||||
|
|
||||||
**Important**: All `device_type` values must be lowercase. The legacy value `"sound_level_meter"` has been deprecated in favor of the shorter `"slm"`. Run `backend/migrate_standardize_device_types.py` to update existing databases.
|
|
||||||
|
|
||||||
### Emitter Table (Device Check-ins)
|
### Emitter Table (Device Check-ins)
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -504,76 +463,6 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
### v0.16.0 — 2026-06-23
|
|
||||||
- **Per-Module Status**: Sound and Vibration are independent modules with their own lifecycle (`active` / `on_hold` / `completed`) — mark the sound study "Completed" while vibration keeps running, instead of archiving the whole project. Shown as badges on the header chips and project cards. (Migration: `backend/migrate_add_module_status.py`.)
|
|
||||||
- **Live Monitoring on the Internal Overview**: The project Overview gains per-NRL live tiles (current level + status), live/offline counts, a "loudest now" readout, and live status chips on the NRL cards — reading SLMM's shared cached feed (no extra device hits). Tiles deep-link to the NRL detail page.
|
|
||||||
- **Browsable Vibration Events**: A project-wide Events sub-tab on the Vibration tab with a Location filter and sortable columns (timestamp / location / serial / Tran / Vert / Long / PVS / Mic); each Vibration location card shows its last event.
|
|
||||||
- **24-Hour Session Period Type**: Full-day (day + night) period for 24/7 jobs; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime.
|
|
||||||
- **Redesigned Project Cards**: Module-mix accent strip, per-module stat lines (replacing the ambiguous Locations / Units / Active row), and Sound / Vibration quick-open buttons that jump straight into a module's tab.
|
|
||||||
- **Project page restructured around modules**: sound-only actions (Combined Report, Night Report, Report Settings) moved from the global header into the Sound tab; Overview locations split into Vibration locations and NRLs.
|
|
||||||
- **Fixes**: false "Unknown error" on SLM start, empty project dropdown + stuck button in the deployment classify modal, deployment GPS dropped when assigning to an existing location (+ Reforward button), unusable event date filters.
|
|
||||||
- **Ships with SLMM v0.4.0** (shared `/monitor` fan-out feed + `'Start'`-state fix).
|
|
||||||
|
|
||||||
### v0.11.0 — 2026-05-15
|
|
||||||
- **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page.
|
|
||||||
- **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view.
|
|
||||||
- **Merge Consecutive Deployments**: Auto-detects runs of same-location assignments within a 7-day gap and offers a one-click "Merge into one" button. Preserves notes, ingest source, and writes an `assignment_merged` audit entry.
|
|
||||||
- **Delete Assignment for Mis-Clicks**: Trash icon on each row of the location's Deployment History panel. Hard-deletes the assignment with a safety check that refuses if real MonitoringSessions sit inside the window (those should go through Unassign instead).
|
|
||||||
- **Drag-to-Reorder Location Cards**: Six-dot drag handle on each card; drop order persists via a new `/locations/reorder` endpoint. Removed locations stay sorted by removal date (their order is historical).
|
|
||||||
- **Three-Dot Kebab Menu**: Replaces the inline Unassign / Edit / Remove / Delete pill row with a single ⋮ menu. Much cleaner card layout, especially for projects with many locations.
|
|
||||||
- **Event Count on Vibration Cards**: Vibration locations now show "{N} events" instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations are unchanged.
|
|
||||||
- **Project Location Map**: Right column of the project overview is now a Leaflet map with a pin per location. Click pin → scrolls + flashes the matching card. Replaces the lightly-used Upcoming Actions panel (still discoverable via a link to the Schedules tab when actions exist).
|
|
||||||
- **Stricter Location Fuzzy Matching**: Metadata-backfill no longer suggests obviously-wrong matches. WRatio was over-confident on location names ("Area 2 - Brookville Dam - Loc 2" vs "Area 1 - Loc 1 - 87 Jenks" used to score 86%); now uses `token_set_ratio` + a multi-digit penalty so disjoint address numbers correctly demote the score.
|
|
||||||
- **Fixed: Multiple typeahead dropdowns weren't clickable**: Same JSON.stringify quote-collision bug surfaced in three places (location Remove button, backfill typeahead, project-merge dropdown). All three fixed by switching to `data-*` attributes + trampoline functions.
|
|
||||||
- **Fixed: Merge-project modal had to be scrolled to see options**: Modal body's `flex-1 overflow-y-auto` collapsed too tight; added `min-height` so the dropdown has room to render below the input.
|
|
||||||
|
|
||||||
### v0.10.0 — 2026-05-14
|
|
||||||
- **SFM Integration**: terra-view now consumes events from the SFM (Seismograph Field Module) backend in real time, with a fleet-wide events page at `/sfm`, per-unit attribution against project assignment windows, and a project-level vibration roll-up that uses SFM data as the single source of truth.
|
|
||||||
- **SFM-Primary Seismograph Status**: Deployed seismograph status (OK/Pending/Missing) now flows from SFM event forwards first; the watcher heartbeat stays as a transparent backup. Each unit's active table row shows a small `SFM` or `HB` badge so operators can see at a glance which signal is currently driving the status.
|
|
||||||
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moves to a horizontal collapsible card below the Fleet Map, auto-expanding only when there's a pending action. Recent Call-Ins now sources from SFM event forwards instead of the legacy watcher-heartbeat endpoint.
|
|
||||||
- **Event Detail Modal**: Click any event anywhere in the app to open a rich detail modal showing peak particle velocity per channel, microphone dB(L), sensor self-check results, device/recording metadata, and download buttons for the original Blastware binary and sidecar JSON. Includes an inline JSON viewer with one-click copy.
|
|
||||||
- **Sortable Events Tables**: Every events table (project events, unit-detail events, fleet-wide /sfm) now supports clickable column-header sorting with directional indicators. Defaults to newest-first.
|
|
||||||
- **Events Attribution & Backfill**: Each SFM event is automatically attributed to a project/location based on `UnitAssignment` time windows. Unattributed events get a diagnostic showing the nearest assignment and a delta-days gap. The metadata-backfill tool in `/tools` scans operator-typed project/sensor-location strings in event sidecars and clusters them via fuzzy matching to propose new assignment retroactives.
|
|
||||||
- **Projects Tools**: New `/tools` workflow hub consolidates Pair Devices, Project Tidy (fuzzy-detect + merge duplicate projects), Metadata Backfill, Reports, and Swap Detection (placeholder).
|
|
||||||
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices).
|
|
||||||
- **Developer → SFM Admin**: New `/admin/sfm` page surfacing SFM health, per-unit roll-up from `/db/units`, recent-events table with forwarding latency (so operators can spot stale watcher forwards), stale-table counts, and a raw API tester. Companion `/admin/slmm` page covers SLMM health + raw API.
|
|
||||||
- **"Overall Peak" excludes False Triggers**: The project-level Overall Peak KPI tile now excludes events flagged as false triggers — operators see the highest real event, not the biggest sensor glitch.
|
|
||||||
- **Synology Deployment Doc**: New `docs/SYNOLOGY_DEPLOYMENT.md` covers migrating the stack to an always-on office NAS, including phased rollout, data rsync, watcher repoint, external-access (Tailscale or reverse-proxy), and rollback plan.
|
|
||||||
|
|
||||||
### v0.8.0 — 2026-03-18
|
|
||||||
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
|
|
||||||
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
|
|
||||||
- **Live Refresh**: Watcher Manager surgically patches status, last-seen, and pending indicators every 30s without a full page reload
|
|
||||||
|
|
||||||
### v0.7.0 — 2026-03-07
|
|
||||||
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
|
|
||||||
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
|
|
||||||
- **Combined Report Wizard**: Multi-session Excel report generation with location grouping, period type filtering, and ZIP download
|
|
||||||
- **NL32 Support**: Report generator and web viewer now handle NL32 measurement data
|
|
||||||
- **Chart Preview**: Live chart preview in the report generator matching final output styling
|
|
||||||
- **Standalone SLM Mode**: SLMs can now be configured without a paired modem (direct IP)
|
|
||||||
- **Vibration Project Isolation**: Vibration project views no longer show SLM-specific tabs
|
|
||||||
- **MonitoringSession Rename**: `RecordingSession` renamed to `MonitoringSession` throughout; run migration before deploying
|
|
||||||
|
|
||||||
### v0.6.1 — 2026-02-16
|
|
||||||
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
|
|
||||||
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
|
|
||||||
- **Scheduler Timezone Fix**: One-off schedule times use local time instead of UTC
|
|
||||||
|
|
||||||
### v0.6.0 — 2026-02-06
|
|
||||||
- **Calendar & Reservation Mode**: Fleet calendar view with device deployment scheduling and reservation system
|
|
||||||
- **Device Pairing Interface**: New `/pair-devices` page with two-column layout for linking recorders with modems, fuzzy-search, and visual pairing workflow
|
|
||||||
- **Calibration UX Overhaul**: Users now set date of previous calibration (not expiry); seismograph list enhanced with color-coded calibration status, filtering, and sorting
|
|
||||||
- **Modem Dashboard**: Model number as dedicated config, modem login links, list view format, and pairing options accessible from modem page
|
|
||||||
- **SLMM Improvements**: Device control lock prevents command flooding, fixed polling intervals and scheduled downloads
|
|
||||||
- **UI Polish**: Tab state persists in URL hash, settings save without reload, scheduler changes cascade to events, fixed mobile type display
|
|
||||||
|
|
||||||
### v0.4.3 — 2026-01-14
|
|
||||||
- **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning.
|
|
||||||
- **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units.
|
|
||||||
- **Project view polish**: FTP browser supports folder downloads, the timer display was reimplemented, and the project/device templates gained edit modals for projects and locations to streamline navigation.
|
|
||||||
- **SLM sync & accuracy**: Configuration edits now propagate to SLMM (which caches configs for faster responses) and the live view uses the correct DRD fields so telemetry aligns with the control center.
|
|
||||||
|
|
||||||
### v0.4.0 — 2025-12-16
|
### v0.4.0 — 2025-12-16
|
||||||
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
||||||
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
|
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
|
||||||
@@ -643,47 +532,9 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.16.0** — Modular projects & live Overview: per-module status (independent sound/vibration lifecycle), internal live-monitoring Overview, browsable vibration events, 24-Hour period type, redesigned project cards (2026-06-23)
|
**Current: 0.4.0** — Database management system with backup/restore and remote cloning (2025-12-16)
|
||||||
|
|
||||||
Previous: 0.15.0 — Operator authentication: deny-by-default login gate + superadmin/admin roles, 30-day session cookie, `/admin/users` (2026-06-18)
|
Previous: 0.3.3 — Mobile navigation improvements and better status visibility (2025-12-12)
|
||||||
|
|
||||||
0.11.0 — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
|
|
||||||
|
|
||||||
0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
|
||||||
|
|
||||||
0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
|
||||||
|
|
||||||
0.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
|
|
||||||
|
|
||||||
0.9.2 — Deployment records, allocated status, quick-info unit modal, inline seismograph editing (2026-03-27)
|
|
||||||
|
|
||||||
0.9.1 — Fix location slots not persisting on save/reload (2026-03-20)
|
|
||||||
|
|
||||||
0.9.0 — Job Planner redesign, monitoring locations, estimated units, smart color picker, calendar bar tooltips, toast notifications (2026-03-19)
|
|
||||||
|
|
||||||
0.8.0 — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
|
|
||||||
|
|
||||||
0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
|
|
||||||
|
|
||||||
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
|
||||||
|
|
||||||
0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
|
||||||
|
|
||||||
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
|
||||||
|
|
||||||
0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
|
||||||
|
|
||||||
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
|
||||||
|
|
||||||
0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
|
||||||
|
|
||||||
0.4.2 — SLM configuration interface with TCP/FTP controls, modem diagnostics, and dashboard endpoints for Sound Level Meters (2026-01-05)
|
|
||||||
|
|
||||||
0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)
|
|
||||||
|
|
||||||
0.4.0 — Database management system with backup/restore and remote cloning (2025-12-16)
|
|
||||||
|
|
||||||
0.3.3 — Mobile navigation improvements and better status visibility (2025-12-12)
|
|
||||||
|
|
||||||
0.3.2 — Progressive Web App with mobile optimization (2025-12-12)
|
0.3.2 — Progressive Web App with mobile optimization (2025-12-12)
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# FTP Report Pipeline — session brief
|
|
||||||
|
|
||||||
**Branch:** `feat/ftp-report-pipeline` (off `dev`), worktree `/home/serversdown/terra-view-reports`.
|
|
||||||
**Scope:** Terra-View only. Do NOT touch SLMM — the SLMM alert/monitor work is live in a
|
|
||||||
parallel session on `slmm` branch `feat/drd-fix`. Pull device data through the **existing**
|
|
||||||
SLMM FTP proxy endpoints; add no SLMM code (for v1).
|
|
||||||
|
|
||||||
See memory note `client_sound_monitoring_job_2026-07` for the client requirements + timeline.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Automated **daily morning report** for the John Myler 3-location sound job: each AM, last
|
|
||||||
night's noise levels vs the **baseline week**, per location. Data pulled from the meters via
|
|
||||||
FTP (the meter records 24/7 to SD regardless of TCP wedges). Alerts are a *separate* workstream
|
|
||||||
(SLMM, real-time DOD) — not in scope here.
|
|
||||||
|
|
||||||
## The big realization (why this is small)
|
|
||||||
The hard parts already exist:
|
|
||||||
- **SLMM (use as-is, via the `/api/slmm/...` proxy):**
|
|
||||||
- `GET /api/slmm/{unit}/ftp/files?path=/NL-43` → list files/folders
|
|
||||||
- `POST /api/slmm/{unit}/ftp/download-folder` → returns the `Auto_####` folder as a **ZIP**
|
|
||||||
- **Terra-View ingest (reuse):** `backend/routers/project_locations.py:1743` `upload_nrl_data`
|
|
||||||
already accepts a **ZIP**, extracts, keeps `.rnh` + `_Leq_ .rnd` (drops `_Lp_`/junk via
|
|
||||||
`_is_wanted`), runs `_parse_rnh` (line 1687) → creates `MonitoringSession` + `DataFile`.
|
|
||||||
- **Report generator (reuse, source-agnostic):** `backend/routers/projects.py`. The `.rnd`
|
|
||||||
file reads funnel through 3 helpers — `_peek_rnd_headers` (~135), `_is_leq_file` (~147),
|
|
||||||
`_read_rnd_file_rows` (~256). `.rnd` files live on disk under `data/{file_path}` (DataFile
|
|
||||||
holds the path, not a BLOB). The stats/Excel/formatting logic doesn't care where bytes come from.
|
|
||||||
|
|
||||||
## Build (Terra-View)
|
|
||||||
1. **Refactor** `upload_nrl_data`'s core into a callable `ingest_nrl_zip(location_id, zip_bytes, db)`
|
|
||||||
so it can be invoked programmatically (not only via HTTP UploadFile).
|
|
||||||
2. **Scheduled pull job** (reuse the existing scheduler): per project location/unit →
|
|
||||||
`GET /ftp/files` to find new `Auto_####` folders → `POST /ftp/download-folder` (zip) →
|
|
||||||
`ingest_nrl_zip(...)`. **Dedup** so repeated pulls don't duplicate sessions/files
|
|
||||||
(track ingested folder names per location).
|
|
||||||
3. **Baseline aggregation:** aggregate the baseline-week `_Leq_` intervals per location →
|
|
||||||
reference values (nighttime Leq, L90 floor, typical Lmax).
|
|
||||||
4. **Nightly report + email:** compute last night's metrics per location, compare to baseline
|
|
||||||
(deltas), render (reuse the Excel/report machinery), email each morning.
|
|
||||||
|
|
||||||
## Data-location decision (light version, agreed)
|
|
||||||
Keep `MonitoringSession`/`DataFile` **metadata in TV** for now; reuse the existing on-disk file
|
|
||||||
store. Optional refinement (later): have SLMM keep the pulled files and TV read them through a
|
|
||||||
SLMM file-serve endpoint (avoids the copy-into-TV step). Don't do that refinement under the
|
|
||||||
deadline unless trivial — the report logic is identical either way.
|
|
||||||
|
|
||||||
## Open questions to resolve early
|
|
||||||
1. **What's actually in a `_Leq_ .rnd`** — Leq only, or Leq + Lmax + Ln per 15-min interval?
|
|
||||||
Decides whether the night-vs-baseline report can show L90/Lmax or just Leq. Inspect a real file.
|
|
||||||
2. **Session rollover / dedup** — does a 2-week run write one growing `Auto_####` folder or new
|
|
||||||
folders? Drives the "what's new" logic.
|
|
||||||
3. **`download-folder` over a multi-day run** — confirm it zips cleanly (size/time).
|
|
||||||
|
|
||||||
## Client params (confirm with Dave before locking)
|
|
||||||
Threshold/metric + their "night" window; report recipients + format (email body vs PDF/Excel).
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
Setup ~7/1–7/2 (baseline week), shutdown week through ~7/17. Reports needed by ~7/8 (before
|
|
||||||
shutdown). Today is ~3 weeks out — reliability > features.
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
API Aggregation Layer - Dashboard endpoints
|
||||||
|
Composes data from multiple feature modules
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard-aggregation"])
|
||||||
|
|
||||||
|
# TODO: Implement aggregation endpoints that combine data from
|
||||||
|
# app.seismo and app.slm modules
|
||||||
|
|
||||||
|
# For now, individual feature modules expose their own APIs directly
|
||||||
|
# Future: Add cross-feature aggregation here
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
API Aggregation Layer - Roster endpoints
|
||||||
|
Aggregates roster data from all feature modules
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster-aggregation", tags=["roster-aggregation"])
|
||||||
|
|
||||||
|
# TODO: Implement unified roster endpoints that combine data from
|
||||||
|
# app.seismo and app.slm modules
|
||||||
|
|
||||||
|
# For now, individual feature modules expose their own roster APIs
|
||||||
|
# Future: Add cross-feature roster aggregation here
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Core configuration for Terra-View application
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME = "Terra-View"
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
PORT = int(os.getenv("PORT", 8001))
|
||||||
|
|
||||||
|
# External Services
|
||||||
|
SLMM_API_URL = os.getenv("SLMM_API_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
# Database URLs (feature-specific)
|
||||||
|
SEISMO_DATABASE_URL = "sqlite:///./data/seismo.db"
|
||||||
|
SLM_DATABASE_URL = "sqlite:///./data/slm.db"
|
||||||
|
MODEM_DATABASE_URL = "sqlite:///./data/modem.db"
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Terra-View - Unified monitoring platform for device fleets
|
||||||
|
Modular monolith architecture with strict feature boundaries
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Import configuration
|
||||||
|
from app.core.config import APP_NAME, VERSION, ENVIRONMENT
|
||||||
|
|
||||||
|
# Import UI routes
|
||||||
|
from app.ui import routes as ui_routes
|
||||||
|
|
||||||
|
# Import feature module routers (seismo)
|
||||||
|
from app.seismo.routers import (
|
||||||
|
roster as seismo_roster,
|
||||||
|
units as seismo_units,
|
||||||
|
photos as seismo_photos,
|
||||||
|
roster_edit as seismo_roster_edit,
|
||||||
|
dashboard as seismo_dashboard,
|
||||||
|
dashboard_tabs as seismo_dashboard_tabs,
|
||||||
|
activity as seismo_activity,
|
||||||
|
seismo_dashboard as seismo_seismo_dashboard,
|
||||||
|
settings as seismo_settings,
|
||||||
|
)
|
||||||
|
from app.seismo import routes as seismo_legacy_routes
|
||||||
|
|
||||||
|
# Import feature module routers (SLM)
|
||||||
|
from app.slm.routers import router as slm_router
|
||||||
|
|
||||||
|
# Import API aggregation layer (placeholder for now)
|
||||||
|
from app.api import dashboard as api_dashboard
|
||||||
|
from app.api import roster as api_roster
|
||||||
|
|
||||||
|
# Initialize database tables
|
||||||
|
from app.seismo.database import engine as seismo_engine, Base as SeismoBase
|
||||||
|
SeismoBase.metadata.create_all(bind=seismo_engine)
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title=APP_NAME,
|
||||||
|
description="Unified monitoring platform for seismograph, modem, and sound level meter fleets",
|
||||||
|
version=VERSION
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add validation error handler to log details
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
||||||
|
logger.error(f"Body: {await request.body()}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": exc.errors()}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
app.mount("/static", StaticFiles(directory="app/ui/static"), name="static")
|
||||||
|
|
||||||
|
# Middleware to add environment to request state
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_environment_to_context(request: Request, call_next):
|
||||||
|
"""Middleware to add environment variable to request state"""
|
||||||
|
request.state.environment = ENVIRONMENT
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ===== INCLUDE ROUTERS =====
|
||||||
|
|
||||||
|
# UI Layer (HTML pages)
|
||||||
|
app.include_router(ui_routes.router)
|
||||||
|
|
||||||
|
# Seismograph Feature Module APIs
|
||||||
|
app.include_router(seismo_roster.router)
|
||||||
|
app.include_router(seismo_units.router)
|
||||||
|
app.include_router(seismo_photos.router)
|
||||||
|
app.include_router(seismo_roster_edit.router)
|
||||||
|
app.include_router(seismo_dashboard.router)
|
||||||
|
app.include_router(seismo_dashboard_tabs.router)
|
||||||
|
app.include_router(seismo_activity.router)
|
||||||
|
app.include_router(seismo_seismo_dashboard.router)
|
||||||
|
app.include_router(seismo_settings.router)
|
||||||
|
app.include_router(seismo_legacy_routes.router)
|
||||||
|
|
||||||
|
# SLM Feature Module APIs
|
||||||
|
app.include_router(slm_router)
|
||||||
|
|
||||||
|
# API Aggregation Layer (future cross-feature endpoints)
|
||||||
|
# app.include_router(api_dashboard.router) # TODO: Implement aggregation
|
||||||
|
# app.include_router(api_roster.router) # TODO: Implement aggregation
|
||||||
|
|
||||||
|
# ===== ADDITIONAL ROUTES FROM OLD MAIN.PY =====
|
||||||
|
# These will need to be migrated to appropriate modules
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from typing import List, Dict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
from app.seismo.models import IgnoredUnit
|
||||||
|
|
||||||
|
# TODO: Move these to appropriate feature modules or UI layer
|
||||||
|
|
||||||
|
@app.post("/api/sync-edits")
|
||||||
|
async def sync_edits(request: dict, db: Session = Depends(get_db)):
|
||||||
|
"""Process offline edit queue and sync to database"""
|
||||||
|
# TODO: Move to seismo module
|
||||||
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
|
class EditItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
unitId: str
|
||||||
|
changes: Dict
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
class SyncEditsRequest(BaseModel):
|
||||||
|
edits: List[EditItem]
|
||||||
|
|
||||||
|
sync_request = SyncEditsRequest(**request)
|
||||||
|
results = []
|
||||||
|
synced_ids = []
|
||||||
|
|
||||||
|
for edit in sync_request.edits:
|
||||||
|
try:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": f"Unit {edit.unitId} not found"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key, value in edit.changes.items():
|
||||||
|
if hasattr(unit, key):
|
||||||
|
if key in ['deployed', 'retired']:
|
||||||
|
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
||||||
|
else:
|
||||||
|
setattr(unit, key, value if value != '' else None)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "success"
|
||||||
|
})
|
||||||
|
synced_ids.append(edit.id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
synced_count = len(synced_ids)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"synced": synced_count,
|
||||||
|
"total": len(sync_request.edits),
|
||||||
|
"synced_ids": synced_ids,
|
||||||
|
"results": results
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"message": f"{APP_NAME} v{VERSION}",
|
||||||
|
"status": "running",
|
||||||
|
"version": VERSION,
|
||||||
|
"modules": ["seismo", "slm"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
from app.core.config import PORT
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Seismograph feature module database connection
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
# For now, we'll use the old database (seismo_fleet.db) until we migrate
|
||||||
|
# TODO: Migrate to seismo.db
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)"""
|
||||||
|
return SessionLocal()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||||
|
from datetime import datetime
|
||||||
|
from app.seismo.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Emitter(Base):
|
||||||
|
__tablename__ = "emitters"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
unit_type = Column(String, nullable=False)
|
||||||
|
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_file = Column(String, nullable=False)
|
||||||
|
status = Column(String, nullable=False)
|
||||||
|
notes = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RosterUnit(Base):
|
||||||
|
"""
|
||||||
|
Roster table: represents our *intended assignment* of a unit.
|
||||||
|
This is editable from the GUI.
|
||||||
|
|
||||||
|
Supports multiple device types (seismograph, modem, sound_level_meter) with type-specific fields.
|
||||||
|
"""
|
||||||
|
__tablename__ = "roster"
|
||||||
|
|
||||||
|
# Core fields (all device types)
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
unit_type = Column(String, default="series3") # Backward compatibility
|
||||||
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter"
|
||||||
|
deployed = Column(Boolean, default=True)
|
||||||
|
retired = Column(Boolean, default=False)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
project_id = Column(String, nullable=True)
|
||||||
|
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||||
|
address = Column(String, nullable=True) # Human-readable address
|
||||||
|
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||||
|
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||||
|
last_calibrated = Column(Date, nullable=True)
|
||||||
|
next_calibration_due = Column(Date, nullable=True)
|
||||||
|
|
||||||
|
# Modem assignment (shared by seismographs and SLMs)
|
||||||
|
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit (device_type=modem)
|
||||||
|
|
||||||
|
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||||
|
ip_address = Column(String, nullable=True)
|
||||||
|
phone_number = Column(String, nullable=True)
|
||||||
|
hardware_model = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||||
|
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||||
|
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||||
|
slm_ftp_port = Column(Integer, nullable=True) # FTP data retrieval port (default 21)
|
||||||
|
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||||
|
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||||
|
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||||
|
slm_time_weighting = Column(String, nullable=True) # F (Fast), S (Slow), I (Impulse)
|
||||||
|
slm_measurement_range = Column(String, nullable=True) # e.g., "30-130 dB"
|
||||||
|
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||||
|
|
||||||
|
|
||||||
|
class IgnoredUnit(Base):
|
||||||
|
"""
|
||||||
|
Ignored units: units that report but should be filtered out from unknown emitters.
|
||||||
|
Used to suppress noise from old projects.
|
||||||
|
"""
|
||||||
|
__tablename__ = "ignored_units"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
reason = Column(String, nullable=True)
|
||||||
|
ignored_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitHistory(Base):
|
||||||
|
"""
|
||||||
|
Unit history: complete timeline of changes to each unit.
|
||||||
|
Tracks note changes, status changes, deployment/benched events, and more.
|
||||||
|
"""
|
||||||
|
__tablename__ = "unit_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||||
|
change_type = Column(String, nullable=False) # note_change, deployed_change, retired_change, etc.
|
||||||
|
field_name = Column(String, nullable=True) # Which field changed
|
||||||
|
old_value = Column(Text, nullable=True) # Previous value
|
||||||
|
new_value = Column(Text, nullable=True) # New value
|
||||||
|
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
source = Column(String, default="manual") # manual, csv_import, telemetry, offline_sync
|
||||||
|
notes = Column(Text, nullable=True) # Optional reason/context for the change
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferences(Base):
|
||||||
|
"""
|
||||||
|
User preferences: persistent storage for application settings.
|
||||||
|
Single-row table (id=1) to store global user preferences.
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, default=1)
|
||||||
|
timezone = Column(String, default="America/New_York")
|
||||||
|
theme = Column(String, default="auto") # auto, light, dark
|
||||||
|
auto_refresh_interval = Column(Integer, default=10) # seconds
|
||||||
|
date_format = Column(String, default="MM/DD/YYYY")
|
||||||
|
table_rows_per_page = Column(Integer, default=25)
|
||||||
|
calibration_interval_days = Column(Integer, default=365)
|
||||||
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -4,30 +4,11 @@ from sqlalchemy import desc
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
import os
|
from app.seismo.database import get_db
|
||||||
import logging
|
from app.seismo.models import UnitHistory, Emitter, RosterUnit
|
||||||
import httpx
|
|
||||||
from backend.database import get_db
|
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["activity"])
|
router = APIRouter(prefix="/api", tags=["activity"])
|
||||||
|
|
||||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
|
||||||
|
|
||||||
|
|
||||||
def _humanize_age(seconds: float) -> str:
|
|
||||||
if seconds < 60:
|
|
||||||
return "just now"
|
|
||||||
if seconds < 3600:
|
|
||||||
return f"{int(seconds / 60)}m ago"
|
|
||||||
if seconds < 86400:
|
|
||||||
hrs = seconds / 3600
|
|
||||||
return f"{int(hrs)}h {int((hrs % 1) * 60)}m ago"
|
|
||||||
return f"{int(seconds / 86400)}d ago"
|
|
||||||
|
|
||||||
PHOTOS_BASE_DIR = Path("data/photos")
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +122,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 +130,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)
|
||||||
|
|
||||||
@@ -164,86 +144,3 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
"hours": hours,
|
"hours": hours,
|
||||||
"time_threshold": time_threshold.isoformat()
|
"time_threshold": time_threshold.isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recent-event-callins")
|
|
||||||
async def get_recent_event_callins(limit: int = 10, db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Recent unit call-ins derived from SFM event forwards.
|
|
||||||
|
|
||||||
Architecture context: the live ACH replacement is on hold, so call-homes
|
|
||||||
arrive as Blastware ACH event files forwarded by series3-watcher and
|
|
||||||
landed in the SFM events store. One event ≈ one call-in. This is the
|
|
||||||
forward-looking source of "recent call-ins" that will eventually replace
|
|
||||||
the heartbeat-based /recent-callins endpoint entirely.
|
|
||||||
|
|
||||||
Each row represents one event; multiple consecutive events from the same
|
|
||||||
serial are intentionally NOT collapsed — each one is a distinct call-home.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{SFM_BASE_URL}/db/events",
|
|
||||||
params={"limit": limit},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
payload = resp.json()
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
log.warning("SFM /db/events failed for recent-event-callins: %s", e)
|
|
||||||
return {"call_ins": [], "total": 0, "error": str(e)}
|
|
||||||
|
|
||||||
events = payload.get("events", []) or []
|
|
||||||
|
|
||||||
# Bulk-resolve serials → roster (single query, no N+1)
|
|
||||||
serials = list({ev.get("serial") for ev in events if ev.get("serial")})
|
|
||||||
roster_map: Dict[str, RosterUnit] = {}
|
|
||||||
if serials:
|
|
||||||
roster_map = {
|
|
||||||
r.id: r
|
|
||||||
for r in db.query(RosterUnit).filter(RosterUnit.id.in_(serials)).all()
|
|
||||||
}
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
call_ins: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
for ev in events:
|
|
||||||
serial = ev.get("serial")
|
|
||||||
if not serial:
|
|
||||||
continue
|
|
||||||
|
|
||||||
roster = roster_map.get(serial)
|
|
||||||
|
|
||||||
# created_at = when SFM received the forward. Falls back to the event
|
|
||||||
# timestamp if the SFM payload didn't carry created_at (older rows).
|
|
||||||
created_at_str = ev.get("created_at") or ev.get("timestamp")
|
|
||||||
time_ago = "—"
|
|
||||||
if created_at_str:
|
|
||||||
try:
|
|
||||||
ts = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
|
||||||
if ts.tzinfo is None:
|
|
||||||
ts = ts.replace(tzinfo=timezone.utc)
|
|
||||||
time_ago = _humanize_age((now - ts).total_seconds())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
call_ins.append({
|
|
||||||
"unit_id": serial,
|
|
||||||
"serial": serial,
|
|
||||||
"event_id": ev.get("id"),
|
|
||||||
"event_timestamp": ev.get("timestamp"),
|
|
||||||
"created_at": ev.get("created_at"),
|
|
||||||
"time_ago": time_ago,
|
|
||||||
"peak_vector_sum": ev.get("peak_vector_sum"),
|
|
||||||
"false_trigger": bool(ev.get("false_trigger")),
|
|
||||||
"sensor_location": ev.get("sensor_location") or "",
|
|
||||||
"project": ev.get("project") or "",
|
|
||||||
"device_type": roster.device_type if roster else "seismograph",
|
|
||||||
"in_roster": roster is not None,
|
|
||||||
"note": (roster.note if roster else "") or "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"call_ins": call_ins,
|
|
||||||
"total": len(call_ins),
|
|
||||||
"source": "sfm-events",
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/active")
|
||||||
|
def dashboard_active(request: Request):
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/active_table.html",
|
||||||
|
{"request": request, "units": snapshot["active"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/benched")
|
||||||
|
def dashboard_benched(request: Request):
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/benched_table.html",
|
||||||
|
{"request": request, "units": snapshot["benched"]}
|
||||||
|
)
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import shutil
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from PIL.ExifTags import TAGS, GPSTAGS
|
from PIL.ExifTags import TAGS, GPSTAGS
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import RosterUnit
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["photos"])
|
router = APIRouter(prefix="/api", tags=["photos"])
|
||||||
|
|
||||||
@@ -2,32 +2,20 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
from backend.services.slm_status_sync import sync_slm_status_to_emitters
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["roster"])
|
router = APIRouter(prefix="/api", tags=["roster"])
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status-snapshot")
|
@router.get("/status-snapshot")
|
||||||
async def get_status_snapshot(db: Session = Depends(get_db)):
|
def get_status_snapshot(db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Calls emit_status_snapshot() to get current fleet status.
|
Calls emit_status_snapshot() to get current fleet status.
|
||||||
Syncs SLM status from SLMM before generating snapshot.
|
This will be replaced with real Series3 emitter logic later.
|
||||||
"""
|
"""
|
||||||
# Sync SLM status from SLMM (with timeout to prevent blocking)
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(sync_slm_status_to_emitters(), timeout=2.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("SLM status sync timed out, using cached data")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"SLM status sync failed: {e}")
|
|
||||||
|
|
||||||
return emit_status_snapshot()
|
return emit_status_snapshot()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# SLMM backend URL for syncing device configs to cache
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
|
||||||
|
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||||
|
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||||
|
"""Helper function to record a change in unit history"""
|
||||||
|
history_entry = UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type=change_type,
|
||||||
|
field_name=field_name,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
changed_at=datetime.utcnow(),
|
||||||
|
source=source,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
# Note: caller is responsible for db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
unit = RosterUnit(id=unit_id)
|
||||||
|
db.add(unit)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(unit)
|
||||||
|
return unit
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_slm_to_slmm_cache(
|
||||||
|
unit_id: str,
|
||||||
|
host: str = None,
|
||||||
|
tcp_port: int = None,
|
||||||
|
ftp_port: int = None,
|
||||||
|
ftp_username: str = None,
|
||||||
|
ftp_password: str = None,
|
||||||
|
deployed_with_modem_id: str = None,
|
||||||
|
db: Session = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Sync SLM device configuration to SLMM backend cache.
|
||||||
|
|
||||||
|
Terra-View is the source of truth for device configs. This function updates
|
||||||
|
SLMM's config cache (NL43Config table) so SLMM can look up device connection
|
||||||
|
info by unit_id without Terra-View passing host:port with every request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unique identifier for the SLM device
|
||||||
|
host: Direct IP address/hostname OR will be resolved from modem
|
||||||
|
tcp_port: TCP control port (default: 2255)
|
||||||
|
ftp_port: FTP port (default: 21)
|
||||||
|
ftp_username: FTP username (optional)
|
||||||
|
ftp_password: FTP password (optional)
|
||||||
|
deployed_with_modem_id: If set, resolve modem IP as host
|
||||||
|
db: Database session for modem lookup
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"success": bool, "message": str}
|
||||||
|
"""
|
||||||
|
# Resolve host from modem if assigned
|
||||||
|
if deployed_with_modem_id and db:
|
||||||
|
modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=deployed_with_modem_id,
|
||||||
|
device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem and modem.ip_address:
|
||||||
|
host = modem.ip_address
|
||||||
|
logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not host:
|
||||||
|
logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided")
|
||||||
|
return {"success": False, "message": "No host IP address available"}
|
||||||
|
|
||||||
|
# Set defaults
|
||||||
|
tcp_port = tcp_port or 2255
|
||||||
|
ftp_port = ftp_port or 21
|
||||||
|
|
||||||
|
# Build SLMM cache payload
|
||||||
|
config_payload = {
|
||||||
|
"host": host,
|
||||||
|
"tcp_port": tcp_port,
|
||||||
|
"tcp_enabled": True,
|
||||||
|
"ftp_enabled": bool(ftp_username and ftp_password),
|
||||||
|
"web_enabled": False
|
||||||
|
}
|
||||||
|
|
||||||
|
if ftp_username and ftp_password:
|
||||||
|
config_payload["ftp_username"] = ftp_username
|
||||||
|
config_payload["ftp_password"] = ftp_password
|
||||||
|
|
||||||
|
# Call SLMM cache update API
|
||||||
|
slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.put(slmm_url, json=config_payload)
|
||||||
|
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
logger.info(f"Successfully synced SLM {unit_id} to SLMM cache")
|
||||||
|
return {"success": True, "message": "Device config cached in SLMM"}
|
||||||
|
else:
|
||||||
|
logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}")
|
||||||
|
return {"success": False, "message": f"SLMM returned status {response.status_code}"}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}")
|
||||||
|
return {"success": False, "message": "SLMM service unavailable"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add")
|
||||||
|
async def add_roster_unit(
|
||||||
|
id: str = Form(...),
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: str = Form(None),
|
||||||
|
retired: str = Form(None),
|
||||||
|
note: str = Form(""),
|
||||||
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated: str = Form(None),
|
||||||
|
next_calibration_due: str = Form(None),
|
||||||
|
deployed_with_modem_id: str = Form(None),
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address: str = Form(None),
|
||||||
|
phone_number: str = Form(None),
|
||||||
|
hardware_model: str = Form(None),
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host: str = Form(None),
|
||||||
|
slm_tcp_port: str = Form(None),
|
||||||
|
slm_ftp_port: str = Form(None),
|
||||||
|
slm_model: str = Form(None),
|
||||||
|
slm_serial_number: str = Form(None),
|
||||||
|
slm_frequency_weighting: str = Form(None),
|
||||||
|
slm_time_weighting: str = Form(None),
|
||||||
|
slm_measurement_range: str = Form(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
|
||||||
|
|
||||||
|
# Convert boolean strings to actual booleans
|
||||||
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
|
||||||
|
# Convert port strings to integers
|
||||||
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||||
|
|
||||||
|
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Unit already exists")
|
||||||
|
|
||||||
|
# Parse date fields if provided
|
||||||
|
last_cal_date = None
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
next_cal_date = None
|
||||||
|
if next_calibration_due:
|
||||||
|
try:
|
||||||
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
unit = RosterUnit(
|
||||||
|
id=id,
|
||||||
|
device_type=device_type,
|
||||||
|
unit_type=unit_type,
|
||||||
|
deployed=deployed_bool,
|
||||||
|
retired=retired_bool,
|
||||||
|
note=note,
|
||||||
|
project_id=project_id,
|
||||||
|
location=location,
|
||||||
|
address=address,
|
||||||
|
coordinates=coordinates,
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated=last_cal_date,
|
||||||
|
next_calibration_due=next_cal_date,
|
||||||
|
deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None,
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address=ip_address if ip_address else None,
|
||||||
|
phone_number=phone_number if phone_number else None,
|
||||||
|
hardware_model=hardware_model if hardware_model else None,
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host=slm_host if slm_host else None,
|
||||||
|
slm_tcp_port=slm_tcp_port_int,
|
||||||
|
slm_ftp_port=slm_ftp_port_int,
|
||||||
|
slm_model=slm_model if slm_model else None,
|
||||||
|
slm_serial_number=slm_serial_number if slm_serial_number else None,
|
||||||
|
slm_frequency_weighting=slm_frequency_weighting if slm_frequency_weighting else None,
|
||||||
|
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||||
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
|
)
|
||||||
|
db.add(unit)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If sound level meter, sync config to SLMM cache
|
||||||
|
if device_type == "sound_level_meter":
|
||||||
|
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
||||||
|
result = await sync_slm_to_slmm_cache(
|
||||||
|
unit_id=id,
|
||||||
|
host=slm_host,
|
||||||
|
tcp_port=slm_tcp_port_int,
|
||||||
|
ftp_port=slm_ftp_port_int,
|
||||||
|
deployed_with_modem_id=deployed_with_modem_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(f"SLMM cache sync warning for {id}: {result['message']}")
|
||||||
|
# Don't fail the operation - device is still added to Terra-View roster
|
||||||
|
# User can manually sync later or SLMM will be synced on next config update
|
||||||
|
|
||||||
|
return {"message": "Unit added", "id": id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modems")
|
||||||
|
def get_modems_list(db: Session = Depends(get_db)):
|
||||||
|
"""Get list of all modem units for dropdown selection"""
|
||||||
|
modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": modem.id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"phone_number": modem.phone_number,
|
||||||
|
"hardware_model": modem.hardware_model,
|
||||||
|
"deployed": modem.deployed
|
||||||
|
}
|
||||||
|
for modem in modems
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{unit_id}")
|
||||||
|
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get a single roster unit by ID"""
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type or "seismograph",
|
||||||
|
"unit_type": unit.unit_type,
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"retired": unit.retired,
|
||||||
|
"note": unit.note or "",
|
||||||
|
"project_id": unit.project_id or "",
|
||||||
|
"location": unit.location or "",
|
||||||
|
"address": unit.address or "",
|
||||||
|
"coordinates": unit.coordinates or "",
|
||||||
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
||||||
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
|
"ip_address": unit.ip_address or "",
|
||||||
|
"phone_number": unit.phone_number or "",
|
||||||
|
"hardware_model": unit.hardware_model or "",
|
||||||
|
"slm_host": unit.slm_host or "",
|
||||||
|
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||||
|
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||||
|
"slm_model": unit.slm_model or "",
|
||||||
|
"slm_serial_number": unit.slm_serial_number or "",
|
||||||
|
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||||
|
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||||
|
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/edit/{unit_id}")
|
||||||
|
def edit_roster_unit(
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: str = Form(None),
|
||||||
|
retired: str = Form(None),
|
||||||
|
note: str = Form(""),
|
||||||
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated: str = Form(None),
|
||||||
|
next_calibration_due: str = Form(None),
|
||||||
|
deployed_with_modem_id: str = Form(None),
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address: str = Form(None),
|
||||||
|
phone_number: str = Form(None),
|
||||||
|
hardware_model: str = Form(None),
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host: str = Form(None),
|
||||||
|
slm_tcp_port: str = Form(None),
|
||||||
|
slm_ftp_port: str = Form(None),
|
||||||
|
slm_model: str = Form(None),
|
||||||
|
slm_serial_number: str = Form(None),
|
||||||
|
slm_frequency_weighting: str = Form(None),
|
||||||
|
slm_time_weighting: str = Form(None),
|
||||||
|
slm_measurement_range: str = Form(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
# Convert boolean strings to actual booleans
|
||||||
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
|
||||||
|
# Convert port strings to integers
|
||||||
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||||
|
|
||||||
|
# Parse date fields if provided
|
||||||
|
last_cal_date = None
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
next_cal_date = None
|
||||||
|
if next_calibration_due:
|
||||||
|
try:
|
||||||
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
# Track changes for history
|
||||||
|
old_note = unit.note
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
old_retired = unit.retired
|
||||||
|
|
||||||
|
# Update all fields
|
||||||
|
unit.device_type = device_type
|
||||||
|
unit.unit_type = unit_type
|
||||||
|
unit.deployed = deployed_bool
|
||||||
|
unit.retired = retired_bool
|
||||||
|
unit.note = note
|
||||||
|
unit.project_id = project_id
|
||||||
|
unit.location = location
|
||||||
|
unit.address = address
|
||||||
|
unit.coordinates = coordinates
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Seismograph-specific fields
|
||||||
|
unit.last_calibrated = last_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
|
||||||
|
|
||||||
|
# Modem-specific fields
|
||||||
|
unit.ip_address = ip_address if ip_address else None
|
||||||
|
unit.phone_number = phone_number if phone_number else None
|
||||||
|
unit.hardware_model = hardware_model if hardware_model else None
|
||||||
|
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
unit.slm_host = slm_host if slm_host else None
|
||||||
|
unit.slm_tcp_port = slm_tcp_port_int
|
||||||
|
unit.slm_ftp_port = slm_ftp_port_int
|
||||||
|
unit.slm_model = slm_model if slm_model else None
|
||||||
|
unit.slm_serial_number = slm_serial_number if slm_serial_number else None
|
||||||
|
unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None
|
||||||
|
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
||||||
|
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
|
||||||
|
|
||||||
|
# Record history entries for changed fields
|
||||||
|
if old_note != note:
|
||||||
|
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||||
|
|
||||||
|
if old_deployed != deployed:
|
||||||
|
status_text = "deployed" if deployed else "benched"
|
||||||
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
|
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
if old_retired != retired:
|
||||||
|
status_text = "retired" if retired else "active"
|
||||||
|
old_status_text = "retired" if old_retired else "active"
|
||||||
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-deployed/{unit_id}")
|
||||||
|
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
unit.deployed = deployed
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for deployed status change
|
||||||
|
if old_deployed != deployed:
|
||||||
|
status_text = "deployed" if deployed else "benched"
|
||||||
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="deployed_change",
|
||||||
|
field_name="deployed",
|
||||||
|
old_value=old_status_text,
|
||||||
|
new_value=status_text,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-retired/{unit_id}")
|
||||||
|
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_retired = unit.retired
|
||||||
|
unit.retired = retired
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for retired status change
|
||||||
|
if old_retired != retired:
|
||||||
|
status_text = "retired" if retired else "active"
|
||||||
|
old_status_text = "retired" if old_retired else "active"
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="retired_change",
|
||||||
|
field_name="retired",
|
||||||
|
old_value=old_status_text,
|
||||||
|
new_value=status_text,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{unit_id}")
|
||||||
|
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Permanently delete a unit from the database.
|
||||||
|
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
|
||||||
|
"""
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
# Try to delete from roster table
|
||||||
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if roster_unit:
|
||||||
|
db.delete(roster_unit)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# Try to delete from emitters table
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
|
||||||
|
if emitter:
|
||||||
|
db.delete(emitter)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# Try to delete from ignored_units table
|
||||||
|
ignored_unit = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||||
|
if ignored_unit:
|
||||||
|
db.delete(ignored_unit)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# If not found in any table, return error
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit deleted", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-note/{unit_id}")
|
||||||
|
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_note = unit.note
|
||||||
|
unit.note = note
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for note change
|
||||||
|
if old_note != note:
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="note_change",
|
||||||
|
field_name="note",
|
||||||
|
old_value=old_note,
|
||||||
|
new_value=note,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-csv")
|
||||||
|
async def import_csv(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
update_existing: bool = Form(True),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Import roster units from CSV file.
|
||||||
|
|
||||||
|
Expected CSV columns (unit_id is required, others are optional):
|
||||||
|
- unit_id: Unique identifier for the unit
|
||||||
|
- unit_type: Type of unit (default: "series3")
|
||||||
|
- deployed: Boolean for deployment status (default: False)
|
||||||
|
- retired: Boolean for retirement status (default: False)
|
||||||
|
- note: Notes about the unit
|
||||||
|
- project_id: Project identifier
|
||||||
|
- location: Location description
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: CSV file upload
|
||||||
|
update_existing: If True, update existing units; if False, skip them
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not file.filename.endswith('.csv'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
contents = await file.read()
|
||||||
|
csv_text = contents.decode('utf-8')
|
||||||
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"added": [],
|
||||||
|
"updated": [],
|
||||||
|
"skipped": [],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 to account for header
|
||||||
|
try:
|
||||||
|
# Validate required field
|
||||||
|
unit_id = row.get('unit_id', '').strip()
|
||||||
|
if not unit_id:
|
||||||
|
results["errors"].append({
|
||||||
|
"row": row_num,
|
||||||
|
"error": "Missing required field: unit_id"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if unit exists
|
||||||
|
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
|
if existing_unit:
|
||||||
|
if not update_existing:
|
||||||
|
results["skipped"].append(unit_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing unit
|
||||||
|
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
||||||
|
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
||||||
|
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
||||||
|
existing_unit.note = row.get('note', existing_unit.note or '')
|
||||||
|
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
||||||
|
existing_unit.location = row.get('location', existing_unit.location)
|
||||||
|
existing_unit.address = row.get('address', existing_unit.address)
|
||||||
|
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
||||||
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
results["updated"].append(unit_id)
|
||||||
|
else:
|
||||||
|
# Create new unit
|
||||||
|
new_unit = RosterUnit(
|
||||||
|
id=unit_id,
|
||||||
|
unit_type=row.get('unit_type', 'series3'),
|
||||||
|
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||||
|
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||||
|
note=row.get('note', ''),
|
||||||
|
project_id=row.get('project_id'),
|
||||||
|
location=row.get('location'),
|
||||||
|
address=row.get('address'),
|
||||||
|
coordinates=row.get('coordinates'),
|
||||||
|
last_updated=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(new_unit)
|
||||||
|
results["added"].append(unit_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({
|
||||||
|
"row": row_num,
|
||||||
|
"unit_id": row.get('unit_id', 'unknown'),
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "CSV import completed",
|
||||||
|
"summary": {
|
||||||
|
"added": len(results["added"]),
|
||||||
|
"updated": len(results["updated"]),
|
||||||
|
"skipped": len(results["skipped"]),
|
||||||
|
"errors": len(results["errors"])
|
||||||
|
},
|
||||||
|
"details": results
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ignore/{unit_id}")
|
||||||
|
def ignore_unit(unit_id: str, reason: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Add a unit to the ignore list to suppress it from unknown emitters.
|
||||||
|
"""
|
||||||
|
# Check if already ignored
|
||||||
|
if db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Unit already ignored")
|
||||||
|
|
||||||
|
ignored = IgnoredUnit(
|
||||||
|
id=unit_id,
|
||||||
|
reason=reason,
|
||||||
|
ignored_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(ignored)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit ignored", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/ignore/{unit_id}")
|
||||||
|
def unignore_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Remove a unit from the ignore list.
|
||||||
|
"""
|
||||||
|
ignored = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||||
|
if not ignored:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not in ignore list")
|
||||||
|
|
||||||
|
db.delete(ignored)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit unignored", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ignored")
|
||||||
|
def list_ignored_units(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get list of all ignored units.
|
||||||
|
"""
|
||||||
|
ignored_units = db.query(IgnoredUnit).all()
|
||||||
|
return {
|
||||||
|
"ignored": [
|
||||||
|
{
|
||||||
|
"id": unit.id,
|
||||||
|
"reason": unit.reason,
|
||||||
|
"ignored_at": unit.ignored_at.isoformat()
|
||||||
|
}
|
||||||
|
for unit in ignored_units
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{unit_id}")
|
||||||
|
def get_unit_history(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get complete history timeline for a unit.
|
||||||
|
Returns all historical changes ordered by most recent first.
|
||||||
|
"""
|
||||||
|
history_entries = db.query(UnitHistory).filter(
|
||||||
|
UnitHistory.unit_id == unit_id
|
||||||
|
).order_by(UnitHistory.changed_at.desc()).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": entry.id,
|
||||||
|
"change_type": entry.change_type,
|
||||||
|
"field_name": entry.field_name,
|
||||||
|
"old_value": entry.old_value,
|
||||||
|
"new_value": entry.new_value,
|
||||||
|
"changed_at": entry.changed_at.isoformat(),
|
||||||
|
"source": entry.source,
|
||||||
|
"notes": entry.notes
|
||||||
|
}
|
||||||
|
for entry in history_entries
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/{history_id}")
|
||||||
|
def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Delete a specific history entry by ID.
|
||||||
|
Allows manual cleanup of old history entries.
|
||||||
|
"""
|
||||||
|
history_entry = db.query(UnitHistory).filter(UnitHistory.id == history_id).first()
|
||||||
|
if not history_entry:
|
||||||
|
raise HTTPException(status_code=404, detail="History entry not found")
|
||||||
|
|
||||||
|
db.delete(history_entry)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "History entry deleted", "id": history_id}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Seismograph Dashboard API Router
|
||||||
|
Provides endpoints for the seismograph-specific dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with seismograph statistics summary
|
||||||
|
"""
|
||||||
|
# Get all seismograph units
|
||||||
|
seismos = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="seismograph",
|
||||||
|
retired=False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total = len(seismos)
|
||||||
|
deployed = sum(1 for s in seismos if s.deployed)
|
||||||
|
benched = sum(1 for s in seismos if not s.deployed)
|
||||||
|
|
||||||
|
# Count modems assigned to deployed seismographs
|
||||||
|
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||||
|
without_modem = deployed - with_modem
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/seismo_stats.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"total": total,
|
||||||
|
"deployed": deployed,
|
||||||
|
"benched": benched,
|
||||||
|
"with_modem": with_modem,
|
||||||
|
"without_modem": without_modem
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_units(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
search: str = Query(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with filterable seismograph unit list
|
||||||
|
"""
|
||||||
|
query = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="seismograph",
|
||||||
|
retired=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||||
|
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||||
|
(RosterUnit.address.ilike(f"%{search}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
seismos = query.order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/seismo_unit_list.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"units": seismos,
|
||||||
|
"search": search or ""
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -9,10 +9,9 @@ import io
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from app.seismo.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from app.seismo.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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,75 +477,3 @@ async def upload_snapshot(file: UploadFile = File(...)):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SLMM SYNC ENDPOINTS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.post("/slmm/sync-all")
|
|
||||||
async def sync_all_slms(db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Manually trigger full sync of all SLM devices from Terra-View roster to SLMM.
|
|
||||||
|
|
||||||
This ensures SLMM database matches Terra-View roster (source of truth).
|
|
||||||
Also cleans up orphaned devices in SLMM that are not in Terra-View.
|
|
||||||
"""
|
|
||||||
from backend.services.slmm_sync import sync_all_slms_to_slmm, cleanup_orphaned_slmm_devices
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Sync all SLMs
|
|
||||||
sync_results = await sync_all_slms_to_slmm(db)
|
|
||||||
|
|
||||||
# Clean up orphaned devices
|
|
||||||
cleanup_results = await cleanup_orphaned_slmm_devices(db)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"sync": sync_results,
|
|
||||||
"cleanup": cleanup_results
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/slmm/status")
|
|
||||||
async def get_slmm_sync_status(db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Get status of SLMM synchronization.
|
|
||||||
|
|
||||||
Shows which devices are in Terra-View roster vs SLMM database.
|
|
||||||
"""
|
|
||||||
from backend.services.slmm_sync import get_slmm_devices
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get devices from both systems
|
|
||||||
roster_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
|
|
||||||
slmm_devices = await get_slmm_devices()
|
|
||||||
|
|
||||||
if slmm_devices is None:
|
|
||||||
raise HTTPException(status_code=503, detail="SLMM service unavailable")
|
|
||||||
|
|
||||||
roster_unit_ids = {unit.unit_type for unit in roster_slms}
|
|
||||||
slmm_unit_ids = set(slmm_devices)
|
|
||||||
|
|
||||||
# Find differences
|
|
||||||
in_roster_only = roster_unit_ids - slmm_unit_ids
|
|
||||||
in_slmm_only = slmm_unit_ids - roster_unit_ids
|
|
||||||
in_both = roster_unit_ids & slmm_unit_ids
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"terra_view_total": len(roster_unit_ids),
|
|
||||||
"slmm_total": len(slmm_unit_ids),
|
|
||||||
"synced": len(in_both),
|
|
||||||
"missing_from_slmm": list(in_roster_only),
|
|
||||||
"orphaned_in_slmm": list(in_slmm_only),
|
|
||||||
"in_sync": len(in_roster_only) == 0 and len(in_slmm_only) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}")
|
||||||
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns detailed data for a single unit.
|
||||||
|
"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
if unit_id not in snapshot["units"]:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
unit_data = snapshot["units"][unit_id]
|
||||||
|
|
||||||
|
# Mock coordinates for now (will be replaced with real data)
|
||||||
|
mock_coords = {
|
||||||
|
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
||||||
|
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
||||||
|
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
||||||
|
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
||||||
|
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
||||||
|
}
|
||||||
|
|
||||||
|
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data["last"],
|
||||||
|
"last_file": unit_data.get("fname", ""),
|
||||||
|
"deployed": unit_data["deployed"],
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"coordinates": coords
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ from pydantic import BaseModel
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import Emitter, WatcherAgent
|
from app.seismo.models import Emitter
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -107,35 +107,6 @@ def get_fleet_status(db: Session = Depends(get_db)):
|
|||||||
emitters = db.query(Emitter).all()
|
emitters = db.query(Emitter).all()
|
||||||
return emitters
|
return emitters
|
||||||
|
|
||||||
# ── Watcher agent upsert helper ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _upsert_watcher_agent(db: Session, source_id: str, source_type: str,
|
|
||||||
version: str, ip_address: str, log_tail: str,
|
|
||||||
status: str) -> None:
|
|
||||||
"""Create or update the WatcherAgent row for a given source_id."""
|
|
||||||
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first()
|
|
||||||
if agent:
|
|
||||||
agent.source_type = source_type
|
|
||||||
agent.version = version
|
|
||||||
agent.last_seen = datetime.utcnow()
|
|
||||||
agent.status = status
|
|
||||||
if ip_address:
|
|
||||||
agent.ip_address = ip_address
|
|
||||||
if log_tail is not None:
|
|
||||||
agent.log_tail = log_tail
|
|
||||||
else:
|
|
||||||
agent = WatcherAgent(
|
|
||||||
id=source_id,
|
|
||||||
source_type=source_type,
|
|
||||||
version=version,
|
|
||||||
last_seen=datetime.utcnow(),
|
|
||||||
status=status,
|
|
||||||
ip_address=ip_address,
|
|
||||||
log_tail=log_tail,
|
|
||||||
)
|
|
||||||
db.add(agent)
|
|
||||||
|
|
||||||
|
|
||||||
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
@@ -149,11 +120,6 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
source = payload.get("source_id")
|
source = payload.get("source_id")
|
||||||
units = payload.get("units", [])
|
units = payload.get("units", [])
|
||||||
version = payload.get("version")
|
|
||||||
log_tail = payload.get("log_tail") # list of strings or None
|
|
||||||
import json as _json
|
|
||||||
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
|
|
||||||
client_ip = request.client.host if request.client else None
|
|
||||||
|
|
||||||
print("\n=== Series 3 Heartbeat ===")
|
print("\n=== Series 3 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -216,27 +182,13 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
if source:
|
|
||||||
_upsert_watcher_agent(db, source, "series3_watcher", version,
|
|
||||||
client_ip, log_tail_str, "ok")
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Check if an update has been triggered for this agent
|
|
||||||
update_available = False
|
|
||||||
if source:
|
|
||||||
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
|
|
||||||
if agent and agent.update_pending:
|
|
||||||
update_available = True
|
|
||||||
agent.update_pending = False
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": results,
|
"results": results
|
||||||
"update_available": update_available,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -267,14 +219,8 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
"""
|
"""
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
|
|
||||||
# Accept source_id (new standard field) with fallback to legacy "source" key
|
source = payload.get("source", "series4_emitter")
|
||||||
source = payload.get("source_id") or payload.get("source", "series4_emitter")
|
|
||||||
units = payload.get("units", [])
|
units = payload.get("units", [])
|
||||||
version = payload.get("version")
|
|
||||||
log_tail = payload.get("log_tail")
|
|
||||||
import json as _json
|
|
||||||
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
|
|
||||||
client_ip = request.client.host if request.client else None
|
|
||||||
|
|
||||||
print("\n=== Series 4 Heartbeat ===")
|
print("\n=== Series 4 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -330,25 +276,11 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
if source:
|
|
||||||
_upsert_watcher_agent(db, source, "series4_watcher", version,
|
|
||||||
client_ip, log_tail_str, "ok")
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Check if an update has been triggered for this agent
|
|
||||||
update_available = False
|
|
||||||
if source:
|
|
||||||
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
|
|
||||||
if agent and agent.update_pending:
|
|
||||||
update_available = True
|
|
||||||
agent.update_pending = False
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": results,
|
"results": results
|
||||||
"update_available": update_available,
|
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from app.seismo.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.seismo.database import get_db_session
|
||||||
|
from app.seismo.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_utc(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def format_age(last_seen):
|
||||||
|
if not last_seen:
|
||||||
|
return "N/A"
|
||||||
|
last_seen = ensure_utc(last_seen)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
diff = now - last_seen
|
||||||
|
hours = diff.total_seconds() // 3600
|
||||||
|
mins = (diff.total_seconds() % 3600) // 60
|
||||||
|
return f"{int(hours)}h {int(mins)}m"
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24):
|
||||||
|
"""
|
||||||
|
Calculate status based on how long ago the unit was last seen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
last_seen: datetime of last seen (UTC)
|
||||||
|
status_ok_threshold: hours before status becomes Pending (default 12)
|
||||||
|
status_pending_threshold: hours before status becomes Missing (default 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"OK", "Pending", or "Missing"
|
||||||
|
"""
|
||||||
|
if not last_seen:
|
||||||
|
return "Missing"
|
||||||
|
|
||||||
|
last_seen = ensure_utc(last_seen)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hours_ago = (now - last_seen).total_seconds() / 3600
|
||||||
|
|
||||||
|
if hours_ago > status_pending_threshold:
|
||||||
|
return "Missing"
|
||||||
|
elif hours_ago > status_ok_threshold:
|
||||||
|
return "Pending"
|
||||||
|
else:
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_status_snapshot():
|
||||||
|
"""
|
||||||
|
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||||
|
Status is recalculated based on current time to ensure accuracy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db = get_db_session()
|
||||||
|
try:
|
||||||
|
# Get user preferences for status thresholds
|
||||||
|
from app.seismo.models import UserPreferences
|
||||||
|
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||||
|
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
||||||
|
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
||||||
|
|
||||||
|
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
||||||
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
|
|
||||||
|
units = {}
|
||||||
|
|
||||||
|
# --- Merge roster entries first ---
|
||||||
|
for unit_id, r in roster.items():
|
||||||
|
e = emitters.get(unit_id)
|
||||||
|
if r.retired:
|
||||||
|
# Retired units get separated later
|
||||||
|
status = "Retired"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
|
else:
|
||||||
|
if e:
|
||||||
|
last_seen = ensure_utc(e.last_seen)
|
||||||
|
# RECALCULATE status based on current time, not stored value
|
||||||
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
|
age = format_age(last_seen)
|
||||||
|
fname = e.last_file
|
||||||
|
else:
|
||||||
|
# Rostered but no emitter data
|
||||||
|
status = "Missing"
|
||||||
|
last_seen = None
|
||||||
|
age = "N/A"
|
||||||
|
fname = ""
|
||||||
|
|
||||||
|
units[unit_id] = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": status,
|
||||||
|
"age": age,
|
||||||
|
"last": last_seen.isoformat() if last_seen else None,
|
||||||
|
"fname": fname,
|
||||||
|
"deployed": r.deployed,
|
||||||
|
"note": r.note or "",
|
||||||
|
"retired": r.retired,
|
||||||
|
# Device type and type-specific fields
|
||||||
|
"device_type": r.device_type or "seismograph",
|
||||||
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
|
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||||
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
|
"ip_address": r.ip_address,
|
||||||
|
"phone_number": r.phone_number,
|
||||||
|
"hardware_model": r.hardware_model,
|
||||||
|
# Location for mapping
|
||||||
|
"location": r.location or "",
|
||||||
|
"address": r.address or "",
|
||||||
|
"coordinates": r.coordinates or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Add unexpected emitter-only units ---
|
||||||
|
for unit_id, e in emitters.items():
|
||||||
|
if unit_id not in roster:
|
||||||
|
last_seen = ensure_utc(e.last_seen)
|
||||||
|
# RECALCULATE status for unknown units too
|
||||||
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
|
units[unit_id] = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": status,
|
||||||
|
"age": format_age(last_seen),
|
||||||
|
"last": last_seen.isoformat(),
|
||||||
|
"fname": e.last_file,
|
||||||
|
"deployed": False, # default
|
||||||
|
"note": "",
|
||||||
|
"retired": False,
|
||||||
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
|
"device_type": "seismograph", # default
|
||||||
|
"last_calibrated": None,
|
||||||
|
"next_calibration_due": None,
|
||||||
|
"deployed_with_modem_id": None,
|
||||||
|
"ip_address": None,
|
||||||
|
"phone_number": None,
|
||||||
|
"hardware_model": None,
|
||||||
|
# Location fields
|
||||||
|
"location": "",
|
||||||
|
"address": "",
|
||||||
|
"coordinates": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Separate buckets for UI
|
||||||
|
active_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
benched_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and not u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
retired_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if u["retired"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unknown units - emitters that aren't in the roster and aren't ignored
|
||||||
|
unknown_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if uid not in roster and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"units": units,
|
||||||
|
"active": active_units,
|
||||||
|
"benched": benched_units,
|
||||||
|
"retired": retired_units,
|
||||||
|
"unknown": unknown_units,
|
||||||
|
"summary": {
|
||||||
|
"total": len(active_units) + len(benched_units),
|
||||||
|
"active": len(active_units),
|
||||||
|
"benched": len(benched_units),
|
||||||
|
"retired": len(retired_units),
|
||||||
|
"unknown": len(unknown_units),
|
||||||
|
# Status counts only for deployed units (active_units)
|
||||||
|
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
||||||
|
"pending": sum(1 for u in active_units.values() if u["status"] == "Pending"),
|
||||||
|
"missing": sum(1 for u in active_units.values() if u["status"] == "Missing"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# SLMM addon package for NL43 integration.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists for the SLMM addon
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/slmm.db"
|
||||||
|
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)."""
|
||||||
|
return SessionLocal()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.slm.database import Base, engine
|
||||||
|
from app.slm import routers
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler("data/slmm.log"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ensure database tables exist for the addon
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Database tables initialized")
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="SLMM NL43 Addon",
|
||||||
|
description="Standalone module for NL43 configuration and status APIs",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS configuration - use environment variable for allowed origins
|
||||||
|
# Default to "*" for development, but should be restricted in production
|
||||||
|
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||||
|
logger.info(f"CORS allowed origins: {allowed_origins}")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=allowed_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
app.include_router(routers.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Basic health check endpoint."""
|
||||||
|
return {"status": "ok", "service": "slmm-nl43-addon"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/devices")
|
||||||
|
async def health_devices():
|
||||||
|
"""Enhanced health check that tests device connectivity."""
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.slm.database import SessionLocal
|
||||||
|
from app.slm.services import NL43Client
|
||||||
|
from app.slm.models import NL43Config
|
||||||
|
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
device_status = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
configs = db.query(NL43Config).filter_by(tcp_enabled=True).all()
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password)
|
||||||
|
status = {
|
||||||
|
"unit_id": cfg.unit_id,
|
||||||
|
"host": cfg.host,
|
||||||
|
"port": cfg.tcp_port,
|
||||||
|
"reachable": False,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to connect (don't send command to avoid rate limiting issues)
|
||||||
|
import asyncio
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=2.0
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
status["reachable"] = True
|
||||||
|
except Exception as e:
|
||||||
|
status["error"] = str(type(e).__name__)
|
||||||
|
logger.warning(f"Device {cfg.unit_id} health check failed: {e}")
|
||||||
|
|
||||||
|
device_status.append(status)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
all_reachable = all(d["reachable"] for d in device_status) if device_status else True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok" if all_reachable else "degraded",
|
||||||
|
"devices": device_status,
|
||||||
|
"total_devices": len(device_status),
|
||||||
|
"reachable_devices": sum(1 for d in device_status if d["reachable"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8100")), reload=True)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, func
|
||||||
|
from app.slm.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Config(Base):
|
||||||
|
"""
|
||||||
|
NL43 connection/config metadata for the standalone SLMM addon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "nl43_config"
|
||||||
|
|
||||||
|
unit_id = Column(String, primary_key=True, index=True)
|
||||||
|
host = Column(String, default="127.0.0.1")
|
||||||
|
tcp_port = Column(Integer, default=80) # NL43 TCP control port (via RX55)
|
||||||
|
tcp_enabled = Column(Boolean, default=True)
|
||||||
|
ftp_enabled = Column(Boolean, default=False)
|
||||||
|
ftp_username = Column(String, nullable=True) # FTP login username
|
||||||
|
ftp_password = Column(String, nullable=True) # FTP login password
|
||||||
|
web_enabled = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Status(Base):
|
||||||
|
"""
|
||||||
|
Latest NL43 status snapshot for quick dashboard/API access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "nl43_status"
|
||||||
|
|
||||||
|
unit_id = Column(String, primary_key=True, index=True)
|
||||||
|
last_seen = Column(DateTime, default=func.now())
|
||||||
|
measurement_state = Column(String, default="unknown") # Measure/Stop
|
||||||
|
measurement_start_time = Column(DateTime, nullable=True) # When measurement started (UTC)
|
||||||
|
counter = Column(String, nullable=True) # d0: Measurement interval counter (1-600)
|
||||||
|
lp = Column(String, nullable=True) # Instantaneous sound pressure level
|
||||||
|
leq = Column(String, nullable=True) # Equivalent continuous sound level
|
||||||
|
lmax = Column(String, nullable=True) # Maximum level
|
||||||
|
lmin = Column(String, nullable=True) # Minimum level
|
||||||
|
lpeak = Column(String, nullable=True) # Peak level
|
||||||
|
battery_level = Column(String, nullable=True)
|
||||||
|
power_source = Column(String, nullable=True)
|
||||||
|
sd_remaining_mb = Column(String, nullable=True)
|
||||||
|
sd_free_ratio = Column(String, nullable=True)
|
||||||
|
raw_payload = Column(Text, nullable=True)
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
"""
|
||||||
|
NL43 TCP connector and snapshot persistence.
|
||||||
|
|
||||||
|
Implements simple per-request TCP calls to avoid long-lived socket complexity.
|
||||||
|
Extend to pooled connections/DRD streaming later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ftplib import FTP
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.slm.models import NL43Status
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NL43Snapshot:
|
||||||
|
unit_id: str
|
||||||
|
measurement_state: str = "unknown"
|
||||||
|
counter: Optional[str] = None # d0: Measurement interval counter (1-600)
|
||||||
|
lp: Optional[str] = None # Instantaneous sound pressure level
|
||||||
|
leq: Optional[str] = None # Equivalent continuous sound level
|
||||||
|
lmax: Optional[str] = None # Maximum level
|
||||||
|
lmin: Optional[str] = None # Minimum level
|
||||||
|
lpeak: Optional[str] = None # Peak level
|
||||||
|
battery_level: Optional[str] = None
|
||||||
|
power_source: Optional[str] = None
|
||||||
|
sd_remaining_mb: Optional[str] = None
|
||||||
|
sd_free_ratio: Optional[str] = None
|
||||||
|
raw_payload: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def persist_snapshot(s: NL43Snapshot, db: Session):
|
||||||
|
"""Persist the latest snapshot for API/dashboard use."""
|
||||||
|
try:
|
||||||
|
row = db.query(NL43Status).filter_by(unit_id=s.unit_id).first()
|
||||||
|
if not row:
|
||||||
|
row = NL43Status(unit_id=s.unit_id)
|
||||||
|
db.add(row)
|
||||||
|
|
||||||
|
row.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
# Track measurement start time by detecting state transition
|
||||||
|
previous_state = row.measurement_state
|
||||||
|
new_state = s.measurement_state
|
||||||
|
|
||||||
|
logger.info(f"State transition check for {s.unit_id}: '{previous_state}' -> '{new_state}'")
|
||||||
|
|
||||||
|
# Device returns "Start" when measuring, "Stop" when stopped
|
||||||
|
# Normalize to previous behavior for backward compatibility
|
||||||
|
is_measuring = new_state == "Start"
|
||||||
|
was_measuring = previous_state == "Start"
|
||||||
|
|
||||||
|
if not was_measuring and is_measuring:
|
||||||
|
# Measurement just started - record the start time
|
||||||
|
row.measurement_start_time = datetime.utcnow()
|
||||||
|
logger.info(f"✓ Measurement started on {s.unit_id} at {row.measurement_start_time}")
|
||||||
|
elif was_measuring and not is_measuring:
|
||||||
|
# Measurement stopped - clear the start time
|
||||||
|
row.measurement_start_time = None
|
||||||
|
logger.info(f"✓ Measurement stopped on {s.unit_id}")
|
||||||
|
|
||||||
|
row.measurement_state = new_state
|
||||||
|
row.counter = s.counter
|
||||||
|
row.lp = s.lp
|
||||||
|
row.leq = s.leq
|
||||||
|
row.lmax = s.lmax
|
||||||
|
row.lmin = s.lmin
|
||||||
|
row.lpeak = s.lpeak
|
||||||
|
row.battery_level = s.battery_level
|
||||||
|
row.power_source = s.power_source
|
||||||
|
row.sd_remaining_mb = s.sd_remaining_mb
|
||||||
|
row.sd_free_ratio = s.sd_free_ratio
|
||||||
|
row.raw_payload = s.raw_payload
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to persist snapshot for unit {s.unit_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Rate limiting: NL43 requires ≥1 second between commands
|
||||||
|
_last_command_time = {}
|
||||||
|
_rate_limit_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Client:
|
||||||
|
def __init__(self, host: str, port: int, timeout: float = 5.0, ftp_username: str = None, ftp_password: str = None):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self.ftp_username = ftp_username or "anonymous"
|
||||||
|
self.ftp_password = ftp_password or ""
|
||||||
|
self.device_key = f"{host}:{port}"
|
||||||
|
|
||||||
|
async def _enforce_rate_limit(self):
|
||||||
|
"""Ensure ≥1 second between commands to the same device."""
|
||||||
|
async with _rate_limit_lock:
|
||||||
|
last_time = _last_command_time.get(self.device_key, 0)
|
||||||
|
elapsed = time.time() - last_time
|
||||||
|
if elapsed < 1.0:
|
||||||
|
wait_time = 1.0 - elapsed
|
||||||
|
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s for {self.device_key}")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
_last_command_time[self.device_key] = time.time()
|
||||||
|
|
||||||
|
async def _send_command(self, cmd: str) -> str:
|
||||||
|
"""Send ASCII command to NL43 device via TCP.
|
||||||
|
|
||||||
|
NL43 protocol returns two lines for query commands:
|
||||||
|
Line 1: Result code (R+0000 for success, error codes otherwise)
|
||||||
|
Line 2: Actual data (for query commands ending with '?')
|
||||||
|
"""
|
||||||
|
await self._enforce_rate_limit()
|
||||||
|
|
||||||
|
logger.info(f"Sending command to {self.device_key}: {cmd.strip()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(self.host, self.port), timeout=self.timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Connection timeout to {self.device_key}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Connection failed to {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
writer.write(cmd.encode("ascii"))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
# Read first line (result code)
|
||||||
|
first_line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
||||||
|
result_code = first_line_data.decode(errors="ignore").strip()
|
||||||
|
|
||||||
|
# Remove leading $ prompt if present
|
||||||
|
if result_code.startswith("$"):
|
||||||
|
result_code = result_code[1:].strip()
|
||||||
|
|
||||||
|
logger.info(f"Result code from {self.device_key}: {result_code}")
|
||||||
|
|
||||||
|
# Check result code
|
||||||
|
if result_code == "R+0000":
|
||||||
|
# Success - for query commands, read the second line with actual data
|
||||||
|
is_query = cmd.strip().endswith("?")
|
||||||
|
if is_query:
|
||||||
|
data_line = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
||||||
|
response = data_line.decode(errors="ignore").strip()
|
||||||
|
logger.debug(f"Data line from {self.device_key}: {response}")
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
# Setting command - return success code
|
||||||
|
return result_code
|
||||||
|
elif result_code == "R+0001":
|
||||||
|
raise ValueError("Command error - device did not recognize command")
|
||||||
|
elif result_code == "R+0002":
|
||||||
|
raise ValueError("Parameter error - invalid parameter value")
|
||||||
|
elif result_code == "R+0003":
|
||||||
|
raise ValueError("Spec/type error - command not supported by this device model")
|
||||||
|
elif result_code == "R+0004":
|
||||||
|
raise ValueError("Status error - device is in wrong state for this command")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown result code: {result_code}")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Response timeout from {self.device_key}")
|
||||||
|
raise TimeoutError(f"Device did not respond within {self.timeout}s")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Communication error with {self.device_key}: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
async def request_dod(self) -> NL43Snapshot:
|
||||||
|
"""Request DOD (Data Output Display) snapshot from device.
|
||||||
|
|
||||||
|
Returns parsed measurement data from the device display.
|
||||||
|
"""
|
||||||
|
# _send_command now handles result code validation and returns the data line
|
||||||
|
resp = await self._send_command("DOD?\r\n")
|
||||||
|
|
||||||
|
# Validate response format
|
||||||
|
if not resp:
|
||||||
|
logger.warning(f"Empty data response from DOD command on {self.device_key}")
|
||||||
|
raise ValueError("Device returned empty data for DOD? command")
|
||||||
|
|
||||||
|
# Remove leading $ prompt if present (shouldn't be there after _send_command, but be safe)
|
||||||
|
if resp.startswith("$"):
|
||||||
|
resp = resp[1:].strip()
|
||||||
|
|
||||||
|
parts = [p.strip() for p in resp.split(",") if p.strip() != ""]
|
||||||
|
|
||||||
|
# DOD should return at least some data points
|
||||||
|
if len(parts) < 2:
|
||||||
|
logger.error(f"Malformed DOD data from {self.device_key}: {resp}")
|
||||||
|
raise ValueError(f"Malformed DOD data: expected comma-separated values, got: {resp}")
|
||||||
|
|
||||||
|
logger.info(f"Parsed {len(parts)} data points from DOD response")
|
||||||
|
|
||||||
|
# Query actual measurement state (DOD doesn't include this information)
|
||||||
|
try:
|
||||||
|
measurement_state = await self.get_measurement_state()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get measurement state, defaulting to 'Measure': {e}")
|
||||||
|
measurement_state = "Measure"
|
||||||
|
|
||||||
|
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state=measurement_state)
|
||||||
|
|
||||||
|
# Parse known positions (based on NL43 communication guide - DRD format)
|
||||||
|
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
||||||
|
try:
|
||||||
|
# Capture d0 (counter) for timer synchronization
|
||||||
|
if len(parts) >= 1:
|
||||||
|
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
||||||
|
if len(parts) >= 3:
|
||||||
|
snap.leq = parts[2] # d2: Equivalent continuous sound level
|
||||||
|
if len(parts) >= 4:
|
||||||
|
snap.lmax = parts[3] # d3: Maximum level
|
||||||
|
if len(parts) >= 5:
|
||||||
|
snap.lmin = parts[4] # d4: Minimum level
|
||||||
|
if len(parts) >= 6:
|
||||||
|
snap.lpeak = parts[5] # d5: Peak level
|
||||||
|
except (IndexError, ValueError) as e:
|
||||||
|
logger.warning(f"Error parsing DOD data points: {e}")
|
||||||
|
|
||||||
|
return snap
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start measurement on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: Measure,Start (no $ prefix, capitalized param)
|
||||||
|
"""
|
||||||
|
await self._send_command("Measure,Start\r\n")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop measurement on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: Measure,Stop (no $ prefix, capitalized param)
|
||||||
|
"""
|
||||||
|
await self._send_command("Measure,Stop\r\n")
|
||||||
|
|
||||||
|
async def set_store_mode_manual(self):
|
||||||
|
"""Set the device to Manual Store mode.
|
||||||
|
|
||||||
|
According to NL43 protocol: Store Mode,Manual sets manual storage mode
|
||||||
|
"""
|
||||||
|
await self._send_command("Store Mode,Manual\r\n")
|
||||||
|
logger.info(f"Store mode set to Manual on {self.device_key}")
|
||||||
|
|
||||||
|
async def manual_store(self):
|
||||||
|
"""Manually store the current measurement data.
|
||||||
|
|
||||||
|
According to NL43 protocol: Manual Store,Start executes storing
|
||||||
|
Parameter p1="Start" executes the storage operation
|
||||||
|
Device must be in Manual Store mode first
|
||||||
|
"""
|
||||||
|
await self._send_command("Manual Store,Start\r\n")
|
||||||
|
logger.info(f"Manual store executed on {self.device_key}")
|
||||||
|
|
||||||
|
async def pause(self):
|
||||||
|
"""Pause the current measurement."""
|
||||||
|
await self._send_command("Pause,On\r\n")
|
||||||
|
logger.info(f"Measurement paused on {self.device_key}")
|
||||||
|
|
||||||
|
async def resume(self):
|
||||||
|
"""Resume a paused measurement."""
|
||||||
|
await self._send_command("Pause,Off\r\n")
|
||||||
|
logger.info(f"Measurement resumed on {self.device_key}")
|
||||||
|
|
||||||
|
async def reset(self):
|
||||||
|
"""Reset the measurement data."""
|
||||||
|
await self._send_command("Reset\r\n")
|
||||||
|
logger.info(f"Measurement data reset on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_measurement_state(self) -> str:
|
||||||
|
"""Get the current measurement state.
|
||||||
|
|
||||||
|
Returns: "Start" if measuring, "Stop" if stopped
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Measure?\r\n")
|
||||||
|
state = resp.strip()
|
||||||
|
logger.info(f"Measurement state on {self.device_key}: {state}")
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def get_battery_level(self) -> str:
|
||||||
|
"""Get the battery level."""
|
||||||
|
resp = await self._send_command("Battery Level?\r\n")
|
||||||
|
logger.info(f"Battery level on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def get_clock(self) -> str:
|
||||||
|
"""Get the device clock time."""
|
||||||
|
resp = await self._send_command("Clock?\r\n")
|
||||||
|
logger.info(f"Clock on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_clock(self, datetime_str: str):
|
||||||
|
"""Set the device clock time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datetime_str: Time in format YYYY/MM/DD,HH:MM:SS or YYYY/MM/DD HH:MM:SS
|
||||||
|
"""
|
||||||
|
# Device expects format: Clock,YYYY/MM/DD HH:MM:SS (space between date and time)
|
||||||
|
# Replace comma with space if present to normalize format
|
||||||
|
normalized = datetime_str.replace(',', ' ', 1)
|
||||||
|
await self._send_command(f"Clock,{normalized}\r\n")
|
||||||
|
logger.info(f"Clock set on {self.device_key} to {normalized}")
|
||||||
|
|
||||||
|
async def get_frequency_weighting(self, channel: str = "Main") -> str:
|
||||||
|
"""Get frequency weighting (A, C, Z, etc.).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
resp = await self._send_command(f"Frequency Weighting ({channel})?\r\n")
|
||||||
|
logger.info(f"Frequency weighting ({channel}) on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_frequency_weighting(self, weighting: str, channel: str = "Main"):
|
||||||
|
"""Set frequency weighting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weighting: A, C, or Z
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Frequency Weighting ({channel}),{weighting}\r\n")
|
||||||
|
logger.info(f"Frequency weighting ({channel}) set to {weighting} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_time_weighting(self, channel: str = "Main") -> str:
|
||||||
|
"""Get time weighting (F, S, I).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
resp = await self._send_command(f"Time Weighting ({channel})?\r\n")
|
||||||
|
logger.info(f"Time weighting ({channel}) on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_time_weighting(self, weighting: str, channel: str = "Main"):
|
||||||
|
"""Set time weighting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weighting: F (Fast), S (Slow), or I (Impulse)
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Time Weighting ({channel}),{weighting}\r\n")
|
||||||
|
logger.info(f"Time weighting ({channel}) set to {weighting} on {self.device_key}")
|
||||||
|
|
||||||
|
async def request_dlc(self) -> dict:
|
||||||
|
"""Request DLC (Data Last Calculation) - final stored measurement results.
|
||||||
|
|
||||||
|
This retrieves the complete calculation results from the last/current measurement,
|
||||||
|
including all statistical data. Similar to DOD but for final results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with parsed DLC data
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("DLC?\r\n")
|
||||||
|
logger.info(f"DLC data received from {self.device_key}: {resp[:100]}...")
|
||||||
|
|
||||||
|
# Parse DLC response - similar format to DOD
|
||||||
|
# The exact format depends on device configuration
|
||||||
|
# For now, return raw data - can be enhanced based on actual response format
|
||||||
|
return {
|
||||||
|
"raw_data": resp.strip(),
|
||||||
|
"device_key": self.device_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def sleep(self):
|
||||||
|
"""Put the device into sleep mode to conserve battery.
|
||||||
|
|
||||||
|
Sleep mode is useful for battery conservation between scheduled measurements.
|
||||||
|
Device can be woken up remotely via TCP command or by pressing a button.
|
||||||
|
"""
|
||||||
|
await self._send_command("Sleep Mode,On\r\n")
|
||||||
|
logger.info(f"Device {self.device_key} entering sleep mode")
|
||||||
|
|
||||||
|
async def wake(self):
|
||||||
|
"""Wake the device from sleep mode.
|
||||||
|
|
||||||
|
Note: This may not work if the device is in deep sleep.
|
||||||
|
Physical button press might be required in some cases.
|
||||||
|
"""
|
||||||
|
await self._send_command("Sleep Mode,Off\r\n")
|
||||||
|
logger.info(f"Device {self.device_key} waking from sleep mode")
|
||||||
|
|
||||||
|
async def get_sleep_status(self) -> str:
|
||||||
|
"""Get the current sleep mode status."""
|
||||||
|
resp = await self._send_command("Sleep Mode?\r\n")
|
||||||
|
logger.info(f"Sleep mode status on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def stream_drd(self, callback):
|
||||||
|
"""Stream continuous DRD output from the device.
|
||||||
|
|
||||||
|
Opens a persistent connection and streams DRD data lines.
|
||||||
|
Calls the provided callback function with each parsed snapshot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Async function that receives NL43Snapshot objects
|
||||||
|
|
||||||
|
The stream continues until an exception occurs or the connection is closed.
|
||||||
|
Send SUB character (0x1A) to stop the stream.
|
||||||
|
"""
|
||||||
|
await self._enforce_rate_limit()
|
||||||
|
|
||||||
|
logger.info(f"Starting DRD stream for {self.device_key}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(self.host, self.port), timeout=self.timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"DRD stream connection timeout to {self.device_key}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DRD stream connection failed to {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start DRD streaming
|
||||||
|
writer.write(b"DRD?\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
# Read initial result code
|
||||||
|
first_line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
||||||
|
result_code = first_line_data.decode(errors="ignore").strip()
|
||||||
|
|
||||||
|
if result_code.startswith("$"):
|
||||||
|
result_code = result_code[1:].strip()
|
||||||
|
|
||||||
|
logger.debug(f"DRD stream result code from {self.device_key}: {result_code}")
|
||||||
|
|
||||||
|
if result_code != "R+0000":
|
||||||
|
raise ValueError(f"DRD stream failed to start: {result_code}")
|
||||||
|
|
||||||
|
logger.info(f"DRD stream started successfully for {self.device_key}")
|
||||||
|
|
||||||
|
# Continuously read data lines
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=30.0)
|
||||||
|
line = line_data.decode(errors="ignore").strip()
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove leading $ if present
|
||||||
|
if line.startswith("$"):
|
||||||
|
line = line[1:].strip()
|
||||||
|
|
||||||
|
# Parse the DRD data (same format as DOD)
|
||||||
|
parts = [p.strip() for p in line.split(",") if p.strip() != ""]
|
||||||
|
|
||||||
|
if len(parts) < 2:
|
||||||
|
logger.warning(f"Malformed DRD data from {self.device_key}: {line}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
snap = NL43Snapshot(unit_id="", raw_payload=line, measurement_state="Measure")
|
||||||
|
|
||||||
|
# Parse known positions (DRD format - same as DOD)
|
||||||
|
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
||||||
|
try:
|
||||||
|
# Capture d0 (counter) for timer synchronization
|
||||||
|
if len(parts) >= 1:
|
||||||
|
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
||||||
|
if len(parts) >= 3:
|
||||||
|
snap.leq = parts[2] # d2: Equivalent continuous sound level
|
||||||
|
if len(parts) >= 4:
|
||||||
|
snap.lmax = parts[3] # d3: Maximum level
|
||||||
|
if len(parts) >= 5:
|
||||||
|
snap.lmin = parts[4] # d4: Minimum level
|
||||||
|
if len(parts) >= 6:
|
||||||
|
snap.lpeak = parts[5] # d5: Peak level
|
||||||
|
except (IndexError, ValueError) as e:
|
||||||
|
logger.warning(f"Error parsing DRD data points: {e}")
|
||||||
|
|
||||||
|
# Call the callback with the snapshot
|
||||||
|
await callback(snap)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"DRD stream timeout (no data for 30s) from {self.device_key}")
|
||||||
|
break
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
logger.info(f"DRD stream closed by device {self.device_key}")
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Send SUB character to stop streaming
|
||||||
|
try:
|
||||||
|
writer.write(b"\x1A")
|
||||||
|
await writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
logger.info(f"DRD stream ended for {self.device_key}")
|
||||||
|
|
||||||
|
async def set_measurement_time(self, preset: str):
|
||||||
|
"""Set measurement time preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Time preset (10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like "00:05:30")
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Measurement Time Preset Manual,{preset}\r\n")
|
||||||
|
logger.info(f"Set measurement time to {preset} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_measurement_time(self) -> str:
|
||||||
|
"""Get current measurement time preset.
|
||||||
|
|
||||||
|
Returns: Current time preset setting
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Measurement Time Preset Manual?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_leq_interval(self, preset: str):
|
||||||
|
"""Set Leq calculation interval preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Interval preset (Off, 10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like "00:05:30")
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Leq Calculation Interval Preset,{preset}\r\n")
|
||||||
|
logger.info(f"Set Leq interval to {preset} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_leq_interval(self) -> str:
|
||||||
|
"""Get current Leq calculation interval preset.
|
||||||
|
|
||||||
|
Returns: Current interval preset setting
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Leq Calculation Interval Preset?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_lp_interval(self, preset: str):
|
||||||
|
"""Set Lp store interval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Store interval (Off, 10ms, 25ms, 100ms, 200ms, 1s)
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Lp Store Interval,{preset}\r\n")
|
||||||
|
logger.info(f"Set Lp interval to {preset} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_lp_interval(self) -> str:
|
||||||
|
"""Get current Lp store interval.
|
||||||
|
|
||||||
|
Returns: Current store interval setting
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Lp Store Interval?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_index_number(self, index: int):
|
||||||
|
"""Set index number for file numbering (Store Name).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Index number (0000-9999)
|
||||||
|
"""
|
||||||
|
if not 0 <= index <= 9999:
|
||||||
|
raise ValueError("Index must be between 0000 and 9999")
|
||||||
|
await self._send_command(f"Store Name,{index:04d}\r\n")
|
||||||
|
logger.info(f"Set store name (index) to {index:04d} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_index_number(self) -> str:
|
||||||
|
"""Get current index number (Store Name).
|
||||||
|
|
||||||
|
Returns: Current index number
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Store Name?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def get_overwrite_status(self) -> str:
|
||||||
|
"""Check if saved data exists at current store target.
|
||||||
|
|
||||||
|
This command checks whether saved data exists in the set store target
|
||||||
|
(store mode / store name / store address). Use this before storing
|
||||||
|
to prevent accidentally overwriting data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"None" - No data exists (safe to store)
|
||||||
|
"Exist" - Data exists (would overwrite)
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Overwrite?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def get_all_settings(self) -> dict:
|
||||||
|
"""Query all device settings for verification.
|
||||||
|
|
||||||
|
Returns: Dictionary with all current device settings
|
||||||
|
"""
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
# Measurement settings
|
||||||
|
try:
|
||||||
|
settings["measurement_state"] = await self.get_measurement_state()
|
||||||
|
except Exception as e:
|
||||||
|
settings["measurement_state"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["frequency_weighting"] = await self.get_frequency_weighting()
|
||||||
|
except Exception as e:
|
||||||
|
settings["frequency_weighting"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["time_weighting"] = await self.get_time_weighting()
|
||||||
|
except Exception as e:
|
||||||
|
settings["time_weighting"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# Timing/interval settings
|
||||||
|
try:
|
||||||
|
settings["measurement_time"] = await self.get_measurement_time()
|
||||||
|
except Exception as e:
|
||||||
|
settings["measurement_time"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["leq_interval"] = await self.get_leq_interval()
|
||||||
|
except Exception as e:
|
||||||
|
settings["leq_interval"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["lp_interval"] = await self.get_lp_interval()
|
||||||
|
except Exception as e:
|
||||||
|
settings["lp_interval"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["index_number"] = await self.get_index_number()
|
||||||
|
except Exception as e:
|
||||||
|
settings["index_number"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# Device info
|
||||||
|
try:
|
||||||
|
settings["battery_level"] = await self.get_battery_level()
|
||||||
|
except Exception as e:
|
||||||
|
settings["battery_level"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["clock"] = await self.get_clock()
|
||||||
|
except Exception as e:
|
||||||
|
settings["clock"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# Sleep mode
|
||||||
|
try:
|
||||||
|
settings["sleep_mode"] = await self.get_sleep_status()
|
||||||
|
except Exception as e:
|
||||||
|
settings["sleep_mode"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# FTP status
|
||||||
|
try:
|
||||||
|
settings["ftp_status"] = await self.get_ftp_status()
|
||||||
|
except Exception as e:
|
||||||
|
settings["ftp_status"] = f"Error: {e}"
|
||||||
|
|
||||||
|
logger.info(f"Retrieved all settings for {self.device_key}")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
async def enable_ftp(self):
|
||||||
|
"""Enable FTP server on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: FTP,On enables the FTP server
|
||||||
|
"""
|
||||||
|
await self._send_command("FTP,On\r\n")
|
||||||
|
logger.info(f"FTP enabled on {self.device_key}")
|
||||||
|
|
||||||
|
async def disable_ftp(self):
|
||||||
|
"""Disable FTP server on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: FTP,Off disables the FTP server
|
||||||
|
"""
|
||||||
|
await self._send_command("FTP,Off\r\n")
|
||||||
|
logger.info(f"FTP disabled on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_ftp_status(self) -> str:
|
||||||
|
"""Query FTP server status on the device.
|
||||||
|
|
||||||
|
Returns: "On" or "Off"
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("FTP?\r\n")
|
||||||
|
logger.info(f"FTP status on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def list_ftp_files(self, remote_path: str = "/") -> List[dict]:
|
||||||
|
"""List files on the device via FTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_path: Directory path on the device (default: root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file info dicts with 'name', 'size', 'modified', 'is_dir'
|
||||||
|
"""
|
||||||
|
logger.info(f"Listing FTP files on {self.device_key} at {remote_path}")
|
||||||
|
|
||||||
|
def _list_ftp_sync():
|
||||||
|
"""Synchronous FTP listing using ftplib (supports active mode)."""
|
||||||
|
ftp = FTP()
|
||||||
|
ftp.set_debuglevel(0)
|
||||||
|
try:
|
||||||
|
# Connect and login
|
||||||
|
ftp.connect(self.host, 21, timeout=10)
|
||||||
|
ftp.login(self.ftp_username, self.ftp_password)
|
||||||
|
ftp.set_pasv(False) # Force active mode
|
||||||
|
|
||||||
|
# Change to target directory
|
||||||
|
if remote_path != "/":
|
||||||
|
ftp.cwd(remote_path)
|
||||||
|
|
||||||
|
# Get directory listing with details
|
||||||
|
files = []
|
||||||
|
lines = []
|
||||||
|
ftp.retrlines('LIST', lines.append)
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Parse Unix-style ls output
|
||||||
|
parts = line.split(None, 8)
|
||||||
|
if len(parts) < 9:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_dir = parts[0].startswith('d')
|
||||||
|
size = int(parts[4]) if not is_dir else 0
|
||||||
|
name = parts[8]
|
||||||
|
|
||||||
|
# Skip . and ..
|
||||||
|
if name in ('.', '..'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse modification time
|
||||||
|
# Format: "Jan 07 14:23" or "Dec 25 2025"
|
||||||
|
modified_str = f"{parts[5]} {parts[6]} {parts[7]}"
|
||||||
|
modified_timestamp = None
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
# Try parsing with time (recent files: "Jan 07 14:23")
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(modified_str, "%b %d %H:%M")
|
||||||
|
# Add current year since it's not in the format
|
||||||
|
dt = dt.replace(year=datetime.now().year)
|
||||||
|
|
||||||
|
# If the resulting date is in the future, it's actually from last year
|
||||||
|
if dt > datetime.now():
|
||||||
|
dt = dt.replace(year=dt.year - 1)
|
||||||
|
|
||||||
|
modified_timestamp = dt.isoformat()
|
||||||
|
except ValueError:
|
||||||
|
# Try parsing with year (older files: "Dec 25 2025")
|
||||||
|
dt = datetime.strptime(modified_str, "%b %d %Y")
|
||||||
|
modified_timestamp = dt.isoformat()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse timestamp '{modified_str}': {e}")
|
||||||
|
|
||||||
|
file_info = {
|
||||||
|
"name": name,
|
||||||
|
"path": f"{remote_path.rstrip('/')}/{name}",
|
||||||
|
"size": size,
|
||||||
|
"modified": modified_str, # Keep original string
|
||||||
|
"modified_timestamp": modified_timestamp, # Add parsed timestamp
|
||||||
|
"is_dir": is_dir,
|
||||||
|
}
|
||||||
|
files.append(file_info)
|
||||||
|
logger.debug(f"Found file: {file_info}")
|
||||||
|
|
||||||
|
logger.info(f"Found {len(files)} files/directories on {self.device_key}")
|
||||||
|
return files
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ftp.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run synchronous FTP in thread pool
|
||||||
|
return await asyncio.to_thread(_list_ftp_sync)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list FTP files on {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"FTP connection failed: {str(e)}")
|
||||||
|
|
||||||
|
async def download_ftp_file(self, remote_path: str, local_path: str):
|
||||||
|
"""Download a file from the device via FTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_path: Full path to file on the device
|
||||||
|
local_path: Local path where file will be saved
|
||||||
|
"""
|
||||||
|
logger.info(f"Downloading {remote_path} from {self.device_key} to {local_path}")
|
||||||
|
|
||||||
|
def _download_ftp_sync():
|
||||||
|
"""Synchronous FTP download using ftplib (supports active mode)."""
|
||||||
|
ftp = FTP()
|
||||||
|
ftp.set_debuglevel(0)
|
||||||
|
try:
|
||||||
|
# Connect and login
|
||||||
|
ftp.connect(self.host, 21, timeout=10)
|
||||||
|
ftp.login(self.ftp_username, self.ftp_password)
|
||||||
|
ftp.set_pasv(False) # Force active mode
|
||||||
|
|
||||||
|
# Download file
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
ftp.retrbinary(f'RETR {remote_path}', f.write)
|
||||||
|
|
||||||
|
logger.info(f"Successfully downloaded {remote_path} to {local_path}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ftp.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run synchronous FTP in thread pool
|
||||||
|
await asyncio.to_thread(_download_ftp_sync)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download {remote_path} from {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"FTP download failed: {str(e)}")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
UI Layer Routes - HTML page routes only (no business logic)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Setup Jinja2 templates
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
# Read environment (development or production)
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
VERSION = "1.0.0" # Terra-View version
|
||||||
|
|
||||||
|
# Override TemplateResponse to include environment and version in context
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
def custom_template_response(name, context=None, *args, **kwargs):
|
||||||
|
if context is None:
|
||||||
|
context = {}
|
||||||
|
context["environment"] = ENVIRONMENT
|
||||||
|
context["version"] = VERSION
|
||||||
|
return original_template_response(name, context, *args, **kwargs)
|
||||||
|
templates.TemplateResponse = custom_template_response
|
||||||
|
|
||||||
|
|
||||||
|
# ===== HTML PAGE ROUTES =====
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
"""Dashboard home page"""
|
||||||
|
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roster", response_class=HTMLResponse)
|
||||||
|
async def roster_page(request: Request):
|
||||||
|
"""Fleet roster page"""
|
||||||
|
return templates.TemplateResponse("roster.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def unit_detail_page(request: Request, unit_id: str):
|
||||||
|
"""Unit detail page"""
|
||||||
|
return templates.TemplateResponse("unit_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit_id": unit_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
"""Settings page for roster management"""
|
||||||
|
return templates.TemplateResponse("settings.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sound-level-meters", response_class=HTMLResponse)
|
||||||
|
async def sound_level_meters_page(request: Request):
|
||||||
|
"""Sound Level Meters management dashboard"""
|
||||||
|
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/seismographs", response_class=HTMLResponse)
|
||||||
|
async def seismographs_page(request: Request):
|
||||||
|
"""Seismographs management dashboard"""
|
||||||
|
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
|
@router.get("/sw.js")
|
||||||
|
async def service_worker():
|
||||||
|
"""Serve service worker with proper headers for PWA"""
|
||||||
|
return FileResponse(
|
||||||
|
"app/ui/static/sw.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={
|
||||||
|
"Service-Worker-Allowed": "/",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/offline-db.js")
|
||||||
|
async def offline_db_script():
|
||||||
|
"""Serve offline database script"""
|
||||||
|
return FileResponse(
|
||||||
|
"app/ui/static/offline-db.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 283 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 283 B |
@@ -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.16.0';
|
|
||||||
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',
|
||||||
@@ -20,9 +20,6 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16.png">
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-192.png">
|
|
||||||
<meta name="theme-color" content="#f48b1c">
|
<meta name="theme-color" content="#f48b1c">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
@@ -71,7 +68,7 @@
|
|||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-slate-800 text-gray-900 dark:text-gray-100">
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
<!-- Offline Indicator -->
|
<!-- Offline Indicator -->
|
||||||
<div id="offlineIndicator" class="offline-indicator">
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
@@ -85,13 +82,13 @@
|
|||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar (Responsive) -->
|
<!-- Sidebar (Responsive) -->
|
||||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<a href="/" class="block">
|
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
||||||
<img src="/static/terra-view-logo-light.png" srcset="/static/terra-view-logo-light.png 1x, /static/terra-view-logo-light@2x.png 2x" alt="Terra-View" class="block dark:hidden w-44 h-auto">
|
Seismo<br>
|
||||||
<img src="/static/terra-view-logo-dark.png" srcset="/static/terra-view-logo-dark.png 1x, /static/terra-view-logo-dark@2x.png 2x" alt="Terra-View" class="hidden dark:block w-44 h-auto">
|
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||||
</a>
|
</h1>
|
||||||
<div class="flex items-center justify-between mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||||
{% if environment == 'development' %}
|
{% if environment == 'development' %}
|
||||||
@@ -109,67 +106,34 @@
|
|||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Devices — single sidebar entry covering all device-type
|
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/roster' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
pages. Lands on /roster (the unified all-devices view);
|
|
||||||
the tab strip on each underlying page lets the operator
|
|
||||||
drill into seismograph / SLM / modem specifics.
|
|
||||||
Active when on any /seismographs, /sound-level-meters,
|
|
||||||
/modems, /roster, /pair-devices, /unit/* page. #}
|
|
||||||
{% set _is_devices = (
|
|
||||||
request.url.path in ('/seismographs', '/sound-level-meters', '/modems', '/roster', '/pair-devices')
|
|
||||||
or request.url.path.startswith('/unit/')
|
|
||||||
or request.url.path.startswith('/slm/')
|
|
||||||
) %}
|
|
||||||
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_devices %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Devices
|
Devices
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
Seismographs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
Sound Level Meters
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Projects
|
Projects
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Events — fleet-wide event database (SFM). Cross-project
|
|
||||||
sortable/filterable event list. Day-to-day event browsing
|
|
||||||
for a specific location or unit lives on those detail
|
|
||||||
pages; this is the firehose for cross-cutting queries. #}
|
|
||||||
<a href="/sfm" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sfm' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
Events
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{# Tools — operator workflow hub. Active when on /tools
|
|
||||||
itself or any of the workflow pages it links into
|
|
||||||
(project tidy, metadata backfill, pair devices). #}
|
|
||||||
{% set _is_tools = (
|
|
||||||
request.url.path == '/tools'
|
|
||||||
or request.url.path.startswith('/tools/')
|
|
||||||
or request.url.path == '/pair-devices'
|
|
||||||
or request.url.path == '/settings/developer/project-tidy'
|
|
||||||
or request.url.path == '/settings/developer/metadata-backfill'
|
|
||||||
) %}
|
|
||||||
<a href="/tools" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_tools %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
||||||
</svg>
|
|
||||||
Tools
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/fleet-calendar" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/fleet-calendar') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
||||||
</svg>
|
|
||||||
Job Planner
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
@@ -205,18 +169,14 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="main-content flex-1 overflow-y-auto">
|
<main class="main-content flex-1 overflow-y-auto">
|
||||||
<div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
|
<div class="p-8">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
Deploy is here because the whole point of the workflow is "I'm
|
<nav class="bottom-nav">
|
||||||
on site, capture this install in 90s before I leave." Devices,
|
|
||||||
Settings, Projects, Job Planner reachable via the hamburger
|
|
||||||
Menu (slot 1) which opens the full sidebar drawer. -->
|
|
||||||
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
|
||||||
<div class="grid grid-cols-4 h-16">
|
<div class="grid grid-cols-4 h-16">
|
||||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -230,18 +190,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/deploy" onclick="window.location.href='/deploy'">
|
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>Deploy</span>
|
<span>Devices</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Events</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -414,10 +374,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Offline Database -->
|
<!-- Offline Database -->
|
||||||
<script src="/static/offline-db.js?v=0.6.1"></script>
|
<script src="/static/offline-db.js?v=0.4.0"></script>
|
||||||
|
|
||||||
<!-- Mobile JavaScript -->
|
<!-- Mobile JavaScript -->
|
||||||
<script src="/static/mobile.js?v=0.6.1"></script>
|
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if environment == 'development' %}
|
||||||
|
<div class="mb-4 p-4 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-200 rounded">
|
||||||
|
<p class="font-bold">Development Environment</p>
|
||||||
|
<p class="text-sm">You are currently viewing the development version of Seismo Fleet Manager.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-8 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
||||||
|
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard cards with auto-refresh -->
|
||||||
|
<div hx-get="/api/status-snapshot"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="updateDashboard(event)">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
|
||||||
|
<!-- Fleet Summary Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-summary-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||||
|
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="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">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||||
|
</div>
|
||||||
|
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||||
|
</div>
|
||||||
|
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||||
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center mb-2" title="Units with delayed reports (12-24 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center" title="Units not reporting (> 24 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Alerts Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recently Called In Units Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="recent-callins-content">
|
||||||
|
<div id="recent-callins-list" class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||||
|
</div>
|
||||||
|
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||||
|
Show all recent call-ins
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Map -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-map-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="fleet-map-content">
|
||||||
|
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Photos Section -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="recent-photos-content">
|
||||||
|
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Status Section with Tabs -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
|
||||||
|
Full Roster
|
||||||
|
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-status-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content" id="fleet-status-content">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||||
|
hx-get="/dashboard/active"
|
||||||
|
hx-target="#fleet-table"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium tab-button"
|
||||||
|
hx-get="/dashboard/benched"
|
||||||
|
hx-target="#fleet-table"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Benched
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content Target -->
|
||||||
|
<div id="fleet-table" class="space-y-2"
|
||||||
|
hx-get="/dashboard/active"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- TAB STYLE -->
|
||||||
|
<style>
|
||||||
|
.tab-button {
|
||||||
|
color: #6b7280; /* gray-500 */
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab-button:hover {
|
||||||
|
color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
.active-tab {
|
||||||
|
color: #b84a12 !important; /* seismo orange */
|
||||||
|
border-bottom: 2px solid #b84a12 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible cards (mobile only) */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card-content.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.chevron.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle card collapse/expand (mobile only)
|
||||||
|
function toggleCard(cardName) {
|
||||||
|
// Only work on mobile
|
||||||
|
if (window.innerWidth >= 768) return;
|
||||||
|
|
||||||
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||||
|
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
|
||||||
|
// Toggle collapsed state
|
||||||
|
const isCollapsed = content.classList.contains('collapsed');
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
content.classList.remove('collapsed');
|
||||||
|
chevron.classList.remove('collapsed');
|
||||||
|
|
||||||
|
// If expanding the fleet map, invalidate size after animation
|
||||||
|
if (cardName === 'fleet-map' && window.fleetMap) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.fleetMap.invalidateSize();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.classList.add('collapsed');
|
||||||
|
chevron.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
|
cardStates[cardName] = !isCollapsed;
|
||||||
|
localStorage.setItem('dashboardCardStates', JSON.stringify(cardStates));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore card states from localStorage on page load
|
||||||
|
function restoreCardStates() {
|
||||||
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||||
|
|
||||||
|
cardNames.forEach(cardName => {
|
||||||
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||||
|
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
|
||||||
|
// Default to expanded (true) if no saved state
|
||||||
|
const isCollapsed = cardStates[cardName] === false;
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
content.classList.add('collapsed');
|
||||||
|
chevron.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore states when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', restoreCardStates);
|
||||||
|
} else {
|
||||||
|
restoreCardStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboard(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.detail.xhr.response);
|
||||||
|
|
||||||
|
// Update "Last updated" timestamp with timezone
|
||||||
|
const now = new Date();
|
||||||
|
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||||
|
document.getElementById('last-refresh').textContent = now.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Fleet summary numbers =====
|
||||||
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
|
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||||
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
|
|
||||||
|
// ===== Device type counts =====
|
||||||
|
let seismoCount = 0;
|
||||||
|
let slmCount = 0;
|
||||||
|
Object.values(data.units || {}).forEach(unit => {
|
||||||
|
if (unit.retired) return; // Don't count retired units
|
||||||
|
const deviceType = unit.device_type || 'seismograph';
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
seismoCount++;
|
||||||
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
|
slmCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('seismo-count').textContent = seismoCount;
|
||||||
|
document.getElementById('slm-count').textContent = slmCount;
|
||||||
|
|
||||||
|
// ===== Alerts =====
|
||||||
|
const alertsList = document.getElementById('alerts-list');
|
||||||
|
// Only show alerts for deployed units that are MISSING (not pending)
|
||||||
|
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
|
||||||
|
|
||||||
|
if (!missingUnits.length) {
|
||||||
|
alertsList.innerHTML =
|
||||||
|
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||||
|
} else {
|
||||||
|
let alertsHtml = '';
|
||||||
|
|
||||||
|
missingUnits.forEach(([id, unit]) => {
|
||||||
|
alertsHtml += `
|
||||||
|
<div class="flex items-start space-x-2 text-sm">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
||||||
|
<div>
|
||||||
|
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
alertsList.innerHTML = alertsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Update Fleet Map =====
|
||||||
|
updateFleetMap(data);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Dashboard update error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab switching
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
// Remove active-tab class from all buttons
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active-tab'));
|
||||||
|
// Add active-tab class to clicked button
|
||||||
|
this.classList.add('active-tab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize fleet map
|
||||||
|
initFleetMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let fleetMap = null;
|
||||||
|
let fleetMarkers = [];
|
||||||
|
let fleetMapInitialized = false;
|
||||||
|
|
||||||
|
// Make fleetMap accessible globally for toggleCard function
|
||||||
|
window.fleetMap = null;
|
||||||
|
|
||||||
|
function initFleetMap() {
|
||||||
|
// Initialize the map centered on the US (can adjust based on your deployment area)
|
||||||
|
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
|
||||||
|
window.fleetMap = fleetMap;
|
||||||
|
|
||||||
|
// Add OpenStreetMap tiles
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 18
|
||||||
|
}).addTo(fleetMap);
|
||||||
|
|
||||||
|
// Force map to recalculate size after a brief delay to ensure container is fully rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
fleetMap.invalidateSize();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFleetMap(data) {
|
||||||
|
if (!fleetMap) return;
|
||||||
|
|
||||||
|
// Clear existing markers
|
||||||
|
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
|
fleetMarkers = [];
|
||||||
|
|
||||||
|
// Get deployed units with coordinates data
|
||||||
|
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
|
||||||
|
|
||||||
|
if (deployedUnits.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = [];
|
||||||
|
|
||||||
|
deployedUnits.forEach(([id, unit]) => {
|
||||||
|
const coords = parseLocation(unit.coordinates);
|
||||||
|
if (coords) {
|
||||||
|
const [lat, lon] = coords;
|
||||||
|
|
||||||
|
// Create marker with custom color based on status
|
||||||
|
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
|
||||||
|
|
||||||
|
const marker = L.circleMarker([lat, lon], {
|
||||||
|
radius: 8,
|
||||||
|
fillColor: markerColor,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8
|
||||||
|
}).addTo(fleetMap);
|
||||||
|
|
||||||
|
// Add popup with unit info
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div class="p-2">
|
||||||
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
|
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||||
|
<p class="text-sm">Type: ${unit.device_type}</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>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
fleetMarkers.push(marker);
|
||||||
|
bounds.push([lat, lon]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit map to show all markers
|
||||||
|
if (bounds.length > 0) {
|
||||||
|
// Use different padding for mobile vs desktop
|
||||||
|
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||||
|
fleetMap.fitBounds(bounds, { padding: padding });
|
||||||
|
fleetMapInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocation(location) {
|
||||||
|
if (!location) return null;
|
||||||
|
|
||||||
|
// Try to parse as "lat,lon" format
|
||||||
|
const parts = location.split(',').map(s => s.trim());
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const lat = parseFloat(parts[0]);
|
||||||
|
const lon = parseFloat(parts[1]);
|
||||||
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add geocoding support for address strings
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and display recent photos
|
||||||
|
async function loadRecentPhotos() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/recent-photos?limit=12');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load recent photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const gallery = document.getElementById('recentPhotosGallery');
|
||||||
|
|
||||||
|
if (data.photos && data.photos.length > 0) {
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
data.photos.forEach(photo => {
|
||||||
|
const photoDiv = document.createElement('div');
|
||||||
|
photoDiv.className = 'relative group';
|
||||||
|
photoDiv.innerHTML = `
|
||||||
|
<a href="/unit/${photo.unit_id}" class="block">
|
||||||
|
<img src="${photo.path}" alt="${photo.unit_id}"
|
||||||
|
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
||||||
|
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
gallery.appendChild(photoDiv);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent photos:', error);
|
||||||
|
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent photos on page load and refresh every 30 seconds
|
||||||
|
loadRecentPhotos();
|
||||||
|
setInterval(loadRecentPhotos, 30000);
|
||||||
|
|
||||||
|
// Load and display recent call-ins
|
||||||
|
let showingAllCallins = false;
|
||||||
|
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||||
|
|
||||||
|
async function loadRecentCallins() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/recent-callins?hours=6');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load recent call-ins');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const callinsList = document.getElementById('recent-callins-list');
|
||||||
|
const showAllButton = document.getElementById('show-all-callins');
|
||||||
|
|
||||||
|
if (data.call_ins && data.call_ins.length > 0) {
|
||||||
|
// Determine how many to show
|
||||||
|
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||||
|
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||||
|
|
||||||
|
// Build HTML for call-ins list
|
||||||
|
let html = '';
|
||||||
|
callinsToDisplay.forEach(callin => {
|
||||||
|
// Status color
|
||||||
|
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||||
|
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||||
|
|
||||||
|
// Build location/note line
|
||||||
|
let subtitle = '';
|
||||||
|
if (callin.location) {
|
||||||
|
subtitle = callin.location;
|
||||||
|
} else if (callin.note) {
|
||||||
|
subtitle = callin.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||||
|
<div>
|
||||||
|
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||||
|
${callin.unit_id}
|
||||||
|
</a>
|
||||||
|
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
callinsList.innerHTML = html;
|
||||||
|
|
||||||
|
// Show/hide the "Show all" button
|
||||||
|
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||||
|
showAllButton.classList.remove('hidden');
|
||||||
|
showAllButton.textContent = showingAllCallins
|
||||||
|
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||||
|
: `Show all (${data.call_ins.length})`;
|
||||||
|
} else {
|
||||||
|
showAllButton.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||||
|
showAllButton.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent call-ins:', error);
|
||||||
|
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle show all/show fewer
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const showAllButton = document.getElementById('show-all-callins');
|
||||||
|
showAllButton.addEventListener('click', function() {
|
||||||
|
showingAllCallins = !showingAllCallins;
|
||||||
|
loadRecentCallins();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load recent call-ins on page load and refresh every 30 seconds
|
||||||
|
loadRecentCallins();
|
||||||
|
setInterval(loadRecentCallins, 30000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -36,14 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Age -->
|
<!-- Age -->
|
||||||
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
<div class="text-right flex-shrink-0">
|
||||||
{% if unit.last_seen_source == 'sfm' %}
|
|
||||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-seismo-orange/10 text-seismo-orange font-semibold"
|
|
||||||
title="Status sourced from SFM event forwards (primary)">SFM</span>
|
|
||||||
{% elif unit.last_seen_source == 'heartbeat' %}
|
|
||||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300"
|
|
||||||
title="Status sourced from watcher heartbeat (backup)">HB</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
|
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
|
||||||
{{ unit.age }}
|
{{ unit.age }}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-id="{{ unit.id }}"
|
data-id="{{ unit.id }}"
|
||||||
data-type="{{ unit.device_type }}"
|
data-type="{{ unit.device_type }}"
|
||||||
@@ -60,13 +60,7 @@
|
|||||||
data-note="{{ unit.note if unit.note else '' }}">
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{% if unit.out_for_calibration %}
|
{% if unit.status == 'OK' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
|
||||||
{% elif unit.allocated %}
|
|
||||||
<span class="w-3 h-3 rounded-full bg-orange-400" title="Allocated"></span>
|
|
||||||
{% elif not unit.deployed %}
|
|
||||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
|
||||||
{% elif unit.status == 'OK' %}
|
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
{% elif unit.status == 'Pending' %}
|
{% elif unit.status == 'Pending' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
@@ -76,10 +70,6 @@
|
|||||||
|
|
||||||
{% if unit.deployed %}
|
{% if unit.deployed %}
|
||||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
{% elif unit.out_for_calibration %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
|
|
||||||
{% elif unit.allocated %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-orange-400" title="Allocated"></span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -95,10 +85,6 @@
|
|||||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
Modem
|
Modem
|
||||||
</span>
|
</span>
|
||||||
{% elif unit.device_type == 'slm' %}
|
|
||||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
|
||||||
SLM
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
Seismograph
|
Seismograph
|
||||||
@@ -114,19 +100,14 @@
|
|||||||
{% if unit.phone_number %}
|
{% if unit.phone_number %}
|
||||||
<div>{{ unit.phone_number }}</div>
|
<div>{{ unit.phone_number }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if unit.deployed_with_unit_id %}
|
{% if unit.hardware_model %}
|
||||||
<div>
|
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||||
<span class="text-gray-500 dark:text-gray-500">Linked:</span>
|
|
||||||
<a href="/unit/{{ unit.deployed_with_unit_id }}" class="text-seismo-orange hover:underline font-medium">
|
|
||||||
{{ unit.deployed_with_unit_id }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if unit.last_calibrated %}
|
{% if unit.next_calibration_due %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
|
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||||
<span class="font-medium">{{ unit.last_calibrated }}</span>
|
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if unit.deployed_with_modem_id %}
|
{% if unit.deployed_with_modem_id %}
|
||||||
@@ -141,7 +122,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 last-seen-cell" data-iso="{{ unit.last_seen }}">{{ unit.last_seen }}</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm
|
<div class="text-sm
|
||||||
@@ -211,20 +192,14 @@
|
|||||||
<div class="unit-card device-card"
|
<div class="unit-card device-card"
|
||||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-unit-id="{{ unit.id }}"
|
data-unit-id="{{ unit.id }}"
|
||||||
data-age="{{ unit.age }}">
|
data-age="{{ unit.age }}">
|
||||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if unit.out_for_calibration %}
|
{% if unit.status == 'OK' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
|
||||||
{% elif unit.allocated %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-orange-400" title="Allocated"></span>
|
|
||||||
{% elif not unit.deployed %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
|
||||||
{% elif unit.status == 'OK' %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
{% elif unit.status == 'Pending' %}
|
{% elif unit.status == 'Pending' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
@@ -236,14 +211,12 @@
|
|||||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
{% elif unit.allocated %}bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300
|
|
||||||
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
|
||||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
{% if unit.out_for_calibration %}Out for Cal{% elif unit.allocated %}Allocated{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,10 +226,6 @@
|
|||||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
Modem
|
Modem
|
||||||
</span>
|
</span>
|
||||||
{% elif unit.device_type == 'slm' %}
|
|
||||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
|
||||||
SLM
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
Seismograph
|
Seismograph
|
||||||
@@ -293,10 +262,6 @@
|
|||||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
⚡ Deployed
|
⚡ Deployed
|
||||||
</span>
|
</span>
|
||||||
{% elif unit.out_for_calibration %}
|
|
||||||
<span class="text-xs text-purple-600 dark:text-purple-400">
|
|
||||||
🔧 Out for Calibration
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
📦 Benched
|
📦 Benched
|
||||||
@@ -376,39 +341,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
|
||||||
// User's configured timezone from settings (defaults to America/New_York)
|
|
||||||
const userTimezone = '{{ user_timezone | default("America/New_York") }}';
|
|
||||||
|
|
||||||
// Format ISO timestamp to human-readable format in user's timezone
|
|
||||||
function formatLastSeenLocal(isoString) {
|
|
||||||
if (!isoString || isoString === 'Never' || isoString === 'N/A') {
|
|
||||||
return isoString || 'Never';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
if (isNaN(date.getTime())) return isoString;
|
|
||||||
|
|
||||||
// Format in user's configured timezone
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
timeZone: userTimezone,
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return isoString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format all last-seen cells on page load
|
|
||||||
document.querySelectorAll('.last-seen-cell').forEach(cell => {
|
|
||||||
const isoDate = cell.getAttribute('data-iso');
|
|
||||||
cell.textContent = formatLastSeenLocal(isoDate);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -429,23 +361,20 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
})();
|
|
||||||
|
|
||||||
// Sorting state (needs to persist across swaps)
|
// Sorting state
|
||||||
if (typeof window.currentSort === 'undefined') {
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
window.currentSort = { column: null, direction: 'asc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (window.currentSort.column === column) {
|
if (currentSort.column === column) {
|
||||||
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
window.currentSort.column = column;
|
currentSort.column = column;
|
||||||
window.currentSort.direction = 'asc';
|
currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -473,8 +402,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,10 +439,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (window.currentSort.column) {
|
if (currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,9 +58,7 @@
|
|||||||
data-note="{{ unit.note if unit.note else '' }}">
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.status == 'OK' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
|
||||||
{% elif unit.status == 'OK' %}
|
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
{% elif unit.status == 'Pending' %}
|
{% elif unit.status == 'Pending' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
@@ -85,10 +83,6 @@
|
|||||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
Modem
|
Modem
|
||||||
</span>
|
</span>
|
||||||
{% elif unit.device_type == 'slm' %}
|
|
||||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
|
||||||
SLM
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
Seismograph
|
Seismograph
|
||||||
@@ -108,10 +102,10 @@
|
|||||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if unit.last_calibrated %}
|
{% if unit.next_calibration_due %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
|
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||||
<span class="font-medium">{{ unit.last_calibrated }}</span>
|
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if unit.deployed_with_modem_id %}
|
{% if unit.deployed_with_modem_id %}
|
||||||
@@ -201,9 +195,7 @@
|
|||||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.status == 'OK' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
|
||||||
{% elif unit.status == 'OK' %}
|
|
||||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
{% elif unit.status == 'Pending' %}
|
{% elif unit.status == 'Pending' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
@@ -230,10 +222,6 @@
|
|||||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
Modem
|
Modem
|
||||||
</span>
|
</span>
|
||||||
{% elif unit.device_type == 'slm' %}
|
|
||||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
|
||||||
SLM
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
Seismograph
|
Seismograph
|
||||||
@@ -349,7 +337,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -370,23 +357,20 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
})();
|
|
||||||
|
|
||||||
// Sorting state (needs to persist across swaps)
|
// Sorting state
|
||||||
if (typeof window.currentSort === 'undefined') {
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
window.currentSort = { column: null, direction: 'asc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (window.currentSort.column === column) {
|
if (currentSort.column === column) {
|
||||||
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
window.currentSort.column = column;
|
currentSort.column = column;
|
||||||
window.currentSort.direction = 'asc';
|
currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -414,8 +398,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -451,10 +435,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (window.currentSort.column) {
|
if (currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,9 +31,6 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||||
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||||
{% if out_for_calibration > 0 %}
|
|
||||||
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ out_for_calibration }} out for calibration</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% if units %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.address %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
{% if unit.note %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if search %}
|
||||||
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Found {{ units|length }} seismograph(s) matching "{{ search }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No seismographs found</h3>
|
||||||
|
{% if search %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No seismographs match "{{ search }}"</p>
|
||||||
|
<button onclick="document.getElementById('seismo-search').value = ''; htmx.trigger('#seismo-search', 'keyup');"
|
||||||
|
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a seismograph unit from the roster page.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-refresh stats every 30 seconds -->
|
||||||
|
<div hx-get="/api/seismo-dashboard/stats"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="mb-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seismograph List -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
||||||
|
|
||||||
|
<!-- Search Box -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="seismo-search"
|
||||||
|
placeholder="Search seismographs..."
|
||||||
|
class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
hx-get="/api/seismo-dashboard/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#seismo-units-list"
|
||||||
|
hx-include="[name='search']"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Units List (loaded via HTMX) -->
|
||||||
|
<div id="seismo-units-list"
|
||||||
|
hx-get="/api/seismo-dashboard/units"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading seismographs...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Clear search input on escape key
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('seismo-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.value = '';
|
||||||
|
htmx.trigger(this, 'keyup');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -41,12 +41,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</button>
|
</button>
|
||||||
<button class="settings-tab text-gray-400 dark:text-gray-500" data-tab="developer" onclick="showTab('developer')">
|
|
||||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
Developer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
@@ -122,21 +116,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">
|
||||||
@@ -370,25 +349,6 @@
|
|||||||
</form>
|
</form>
|
||||||
<div id="uploadResult" class="hidden mt-3"></div>
|
<div id="uploadResult" class="hidden mt-3"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Deleted Projects -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Deleted Projects</h2>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Projects that have been soft-deleted. Restore them or permanently remove them.</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="loadDeletedProjects()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex items-center gap-2">
|
|
||||||
<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>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="deletedProjectsList">
|
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -472,20 +432,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>
|
||||||
@@ -568,78 +514,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Developer Tab -->
|
|
||||||
<div id="developer-tab" class="tab-content hidden">
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-1">Developer Tools</h2>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Admin-only tools for managing field watcher agents and diagnosing connectivity.</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Watcher Manager -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">Watcher Manager</div>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
Monitor series3-watcher and thor-watcher agents. View status, log tails, and push remote updates.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="/admin/watchers"
|
|
||||||
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SFM Admin -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">SFM Admin</div>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
Diagnose the SFM backend — health, per-unit event counts, forwarding latency, stale tables, raw API probe.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="/admin/sfm"
|
|
||||||
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SLMM Admin -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">SLMM Admin</div>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
Diagnose the SLMM backend — health check + raw API probe. For per-device control use the SLM dashboard.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="/admin/slmm"
|
|
||||||
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SFM Event DB Manager -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">SFM Event DB Manager</div>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
Browse, flag, and <strong>delete</strong> events from SFM's events table across all units. Destructive — also cleans up on-disk waveform files. Use for cleaning bogus events from a misbehaving unit.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="/admin/events"
|
|
||||||
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
|
||||||
operator workflows, not admin/dev surfaces). Find them
|
|
||||||
at /tools. #}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
@@ -678,67 +552,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ========== DELETED PROJECTS ==========
|
|
||||||
|
|
||||||
async function loadDeletedProjects() {
|
|
||||||
const container = document.getElementById('deletedProjectsList');
|
|
||||||
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>';
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/projects/deleted');
|
|
||||||
const projects = await resp.json();
|
|
||||||
if (!projects.length) {
|
|
||||||
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">No deleted projects.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
${projects.map(p => `
|
|
||||||
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900/30">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">${p.name}</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
${p.client_name ? p.client_name + ' · ' : ''}Deleted ${new Date(p.deleted_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 ml-4">
|
|
||||||
<button onclick="restoreProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
|
||||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
<button onclick="permanentlyDeleteProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
|
||||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
|
||||||
Delete Permanently
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>`;
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = '<p class="text-sm text-red-500">Failed to load deleted projects.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreProject(projectId, name) {
|
|
||||||
if (!confirm(`Restore "${name}"?`)) return;
|
|
||||||
const resp = await fetch(`/api/projects/${projectId}/restore`, { method: 'POST' });
|
|
||||||
if (resp.ok) {
|
|
||||||
loadDeletedProjects();
|
|
||||||
} else {
|
|
||||||
const d = await resp.json();
|
|
||||||
alert('Failed to restore: ' + (d.detail || 'Unknown error'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function permanentlyDeleteProject(projectId, name) {
|
|
||||||
if (!confirm(`Permanently delete "${name}" and all its data? This cannot be undone.`)) return;
|
|
||||||
const resp = await fetch(`/api/projects/${projectId}/permanent`, { method: 'DELETE' });
|
|
||||||
if (resp.ok) {
|
|
||||||
loadDeletedProjects();
|
|
||||||
} else {
|
|
||||||
const d = await resp.json();
|
|
||||||
alert('Failed to delete: ' + (d.detail || 'Unknown error'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== TAB MANAGEMENT ==========
|
// ========== TAB MANAGEMENT ==========
|
||||||
|
|
||||||
function showTab(tabName) {
|
function showTab(tabName) {
|
||||||
@@ -764,10 +577,9 @@ function showTab(tabName) {
|
|||||||
// Save last active tab to localStorage
|
// Save last active tab to localStorage
|
||||||
localStorage.setItem('settings-last-tab', tabName);
|
localStorage.setItem('settings-last-tab', tabName);
|
||||||
|
|
||||||
// Load roster table and deleted projects when data tab is shown
|
// Load roster table when data tab is shown
|
||||||
if (tabName === 'data') {
|
if (tabName === 'data') {
|
||||||
loadRosterTable();
|
loadRosterTable();
|
||||||
loadDeletedProjects();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,9 +612,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 +629,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 +637,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 +711,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
|
||||||
@@ -1087,6 +859,11 @@ async function loadRosterTable() {
|
|||||||
|
|
||||||
function createRosterRow(unit) {
|
function createRosterRow(unit) {
|
||||||
const statusBadges = [];
|
const statusBadges = [];
|
||||||
|
if (unit.deployed) {
|
||||||
|
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">Deployed</span>');
|
||||||
|
} else {
|
||||||
|
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Benched</span>');
|
||||||
|
}
|
||||||
if (unit.retired) {
|
if (unit.retired) {
|
||||||
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
|
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
|
||||||
}
|
}
|
||||||
@@ -1103,24 +880,8 @@ function createRosterRow(unit) {
|
|||||||
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-wrap gap-1">
|
||||||
<div class="flex gap-3">
|
${statusBadges.join('')}
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="radio" name="deployed-${unit.id}" value="true"
|
|
||||||
${unit.deployed ? 'checked' : ''}
|
|
||||||
onchange="toggleDeployed('${unit.id}', true)"
|
|
||||||
class="w-4 h-4 text-green-600 focus:ring-green-500">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="radio" name="deployed-${unit.id}" value="false"
|
|
||||||
${!unit.deployed ? 'checked' : ''}
|
|
||||||
onchange="toggleDeployed('${unit.id}', false)"
|
|
||||||
class="w-4 h-4 text-gray-600 focus:ring-gray-500">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Benched</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
${statusBadges.length > 0 ? '<div class="flex flex-wrap gap-1">' + statusBadges.join('') + '</div>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -1135,6 +896,13 @@ function createRosterRow(unit) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
|
<button onclick="toggleDeployed('${unit.id}', ${unit.deployed})"
|
||||||
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||||
|
title="${unit.deployed ? 'Bench Unit' : 'Deploy Unit'}">
|
||||||
|
<svg class="w-4 h-4 ${unit.deployed ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
||||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||||
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
||||||
@@ -1162,12 +930,12 @@ function createRosterRow(unit) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleDeployed(unitId, newState) {
|
async function toggleDeployed(unitId, currentState) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: `deployed=${newState}`
|
body: `deployed=${!currentState}`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -1201,7 +969,7 @@ async function toggleRetired(unitId, currentState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editUnit(unitId) {
|
function editUnit(unitId) {
|
||||||
window.location.href = `/roster?edit=${unitId}`;
|
window.location.href = `/unit/${unitId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteUnit(unitId) {
|
async function confirmDeleteUnit(unitId) {
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" 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"></path>
|
||||||
|
</svg>
|
||||||
|
Back to Roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
{{ unit_id }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
|
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Panel -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
||||||
|
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real-time Data Stream -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div id="slm-stream-container">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<button onclick="startStream()"
|
||||||
|
id="stream-start-btn"
|
||||||
|
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
||||||
|
Start Real-time Stream
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
||||||
|
</div>
|
||||||
|
<div id="stream-data" class="hidden">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
||||||
|
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
||||||
|
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
||||||
|
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
||||||
|
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
|
Streaming
|
||||||
|
</div>
|
||||||
|
<button onclick="stopStream()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Stop Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Information -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function startStream() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('stream-start-btn').classList.add('hidden');
|
||||||
|
document.getElementById('stream-data').classList.remove('hidden');
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error('Stream error:', data.error);
|
||||||
|
stopStream();
|
||||||
|
alert('Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update values
|
||||||
|
document.getElementById('stream-lp').textContent = data.lp || '--';
|
||||||
|
document.getElementById('stream-leq').textContent = data.leq || '--';
|
||||||
|
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
||||||
|
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
stopStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
document.getElementById('stream-start-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('stream-data').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||||
|
hx-get="/api/slm-dashboard/stats"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Stats will be loaded here -->
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- SLM List -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
||||||
|
|
||||||
|
<!-- Search/Filter -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input type="text"
|
||||||
|
placeholder="Search units..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
hx-get="/api/slm-dashboard/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#slm-list"
|
||||||
|
hx-include="this"
|
||||||
|
name="search">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SLM List -->
|
||||||
|
<div id="slm-list"
|
||||||
|
class="space-y-2 max-h-[600px] overflow-y-auto"
|
||||||
|
hx-get="/api/slm-dashboard/units"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div class="animate-pulse space-y-2">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live View Panel -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div id="live-view-panel" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<!-- Initial state - no unit selected -->
|
||||||
|
<div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
|
||||||
|
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium">No unit selected</p>
|
||||||
|
<p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Modal -->
|
||||||
|
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
||||||
|
<button onclick="closeConfigModal()" 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 id="config-modal-content">
|
||||||
|
<!-- Content loaded via HTMX -->
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to select a unit and load live view
|
||||||
|
function selectUnit(unitId) {
|
||||||
|
// Remove active state from all items
|
||||||
|
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
||||||
|
item.classList.remove('bg-seismo-orange', 'text-white');
|
||||||
|
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active state to clicked item
|
||||||
|
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
||||||
|
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
|
||||||
|
// Load live view for this unit
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||||
|
target: '#live-view-panel',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration modal functions
|
||||||
|
function openConfigModal(unitId) {
|
||||||
|
const modal = document.getElementById('config-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load configuration form via HTMX
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||||
|
target: '#config-modal-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigModal() {
|
||||||
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket for selected unit
|
||||||
|
let currentWebSocket = null;
|
||||||
|
|
||||||
|
function initLiveDataStream(unitId) {
|
||||||
|
// Close existing connection if any
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket URL for SLMM backend via proxy
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||||
|
|
||||||
|
currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
currentWebSocket.onopen = function() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
|
if (stopBtn) stopBtn.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWebSocket.onmessage = function(event) {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
updateLiveChart(data);
|
||||||
|
updateLiveMetrics(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWebSocket.onerror = function(error) {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWebSocket.onclose = function() {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'flex';
|
||||||
|
if (stopBtn) stopBtn.style.display = 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLiveDataStream() {
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
currentWebSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update live chart with new data point
|
||||||
|
let chartData = {
|
||||||
|
timestamps: [],
|
||||||
|
lp: [],
|
||||||
|
leq: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateLiveChart(data) {
|
||||||
|
const now = new Date();
|
||||||
|
chartData.timestamps.push(now.toLocaleTimeString());
|
||||||
|
chartData.lp.push(parseFloat(data.lp || 0));
|
||||||
|
chartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
|
||||||
|
// Keep only last 60 data points (1 minute at 1 sample/sec)
|
||||||
|
if (chartData.timestamps.length > 60) {
|
||||||
|
chartData.timestamps.shift();
|
||||||
|
chartData.lp.shift();
|
||||||
|
chartData.leq.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart (using Chart.js if available)
|
||||||
|
if (window.liveChart) {
|
||||||
|
window.liveChart.data.labels = chartData.timestamps;
|
||||||
|
window.liveChart.data.datasets[0].data = chartData.lp;
|
||||||
|
window.liveChart.data.datasets[1].data = chartData.leq;
|
||||||
|
window.liveChart.update('none'); // Update without animation for smooth real-time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLiveMetrics(data) {
|
||||||
|
// Update metric displays
|
||||||
|
if (document.getElementById('live-lp')) {
|
||||||
|
document.getElementById('live-lp').textContent = data.lp || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-leq')) {
|
||||||
|
document.getElementById('live-leq').textContent = data.leq || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lmax')) {
|
||||||
|
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lmin')) {
|
||||||
|
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
Before Width: | Height: | Size: 36 KiB |
@@ -1,64 +0,0 @@
|
|||||||
# backend/auth_cookies.py
|
|
||||||
"""Generic HMAC-signed cookie payloads, shared by operator auth (and, optionally
|
|
||||||
later, the portal). A signed value is f"{b64url(json)}.{hmac_sha256(b64)}"; read()
|
|
||||||
verifies the signature in constant time and enforces a server-side iat expiry.
|
|
||||||
|
|
||||||
The signing secret is the same SECRET_KEY the portal already reads, so a single
|
|
||||||
env var protects both cookies. Never store or log raw secrets."""
|
|
||||||
import os
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Same env var the portal cookie uses — one secret protects both. The insecure
|
|
||||||
# default only exists so dev/test boots without config; set a real SECRET_KEY in prod.
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
|
|
||||||
# Set COOKIE_SECURE=true once served over HTTPS; leave false on plain HTTP or the
|
|
||||||
# browser won't send the cookie.
|
|
||||||
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(body: str) -> str:
|
|
||||||
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def sign(payload: dict) -> str:
|
|
||||||
"""Serialize + sign a payload dict into a cookie-safe string."""
|
|
||||||
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
|
||||||
return f"{body}.{_sign(body)}"
|
|
||||||
|
|
||||||
|
|
||||||
def read(raw, max_age: int):
|
|
||||||
"""Verify a signed value and return its payload dict, or None if missing,
|
|
||||||
tampered, or older than max_age seconds (by its own `iat`)."""
|
|
||||||
if not raw or not isinstance(raw, str):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
body, sig = raw.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()))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
iat = data.get("iat")
|
|
||||||
if not isinstance(iat, (int, float)):
|
|
||||||
return None
|
|
||||||
now = time.time()
|
|
||||||
# Reject implausibly future-dated tokens: the same server signs and verifies,
|
|
||||||
# so there's no real clock skew — a far-future iat (e.g. to dodge max_age or
|
|
||||||
# outlive a sessions_valid_from bump) is bogus. 60s of slack is generous.
|
|
||||||
if iat - now > 60:
|
|
||||||
return None
|
|
||||||
if (now - iat) > max_age:
|
|
||||||
return None
|
|
||||||
return data
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Password hashing for the client portal — argon2id via argon2-cffi.
|
|
||||||
|
|
||||||
Kept separate from portal_auth (cookie signing) so the future operator auth can
|
|
||||||
reuse the same hasher. Never store or log raw passwords."""
|
|
||||||
import secrets
|
|
||||||
from argon2 import PasswordHasher
|
|
||||||
|
|
||||||
_ph = PasswordHasher()
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(raw: str) -> str:
|
|
||||||
"""Return an argon2id hash string for a raw password."""
|
|
||||||
return _ph.hash(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(raw: str, hashed: str) -> bool:
|
|
||||||
"""True iff raw matches the stored hash. Never raises."""
|
|
||||||
try:
|
|
||||||
return _ph.verify(hashed, raw)
|
|
||||||
except Exception: # argon2 raises on mismatch/garbage; treat all as "no match"
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def generate_password(n_bytes: int = 12) -> str:
|
|
||||||
"""A strong, URL-safe shareable password (~16 chars for n_bytes=12)."""
|
|
||||||
return secrets.token_urlsafe(n_bytes)
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Database initialization script for Projects system.
|
|
||||||
|
|
||||||
This script creates the new project management tables and populates
|
|
||||||
the project_types table with default templates.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python -m backend.init_projects_db
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from backend.database import engine, SessionLocal
|
|
||||||
from backend.models import (
|
|
||||||
Base,
|
|
||||||
ProjectType,
|
|
||||||
Project,
|
|
||||||
MonitoringLocation,
|
|
||||||
UnitAssignment,
|
|
||||||
ScheduledAction,
|
|
||||||
MonitoringSession,
|
|
||||||
DataFile,
|
|
||||||
)
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
def init_project_types(db: Session):
|
|
||||||
"""Initialize default project types."""
|
|
||||||
project_types = [
|
|
||||||
{
|
|
||||||
"id": "sound_monitoring",
|
|
||||||
"name": "Sound Monitoring",
|
|
||||||
"description": "Noise monitoring projects with sound level meters and NRLs (Noise Recording Locations)",
|
|
||||||
"icon": "volume-2", # Lucide icon name
|
|
||||||
"supports_sound": True,
|
|
||||||
"supports_vibration": False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "vibration_monitoring",
|
|
||||||
"name": "Vibration Monitoring",
|
|
||||||
"description": "Seismic/vibration monitoring projects with seismographs and monitoring points",
|
|
||||||
"icon": "activity", # Lucide icon name
|
|
||||||
"supports_sound": False,
|
|
||||||
"supports_vibration": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "combined",
|
|
||||||
"name": "Combined Monitoring",
|
|
||||||
"description": "Full-spectrum monitoring with both sound and vibration capabilities",
|
|
||||||
"icon": "layers", # Lucide icon name
|
|
||||||
"supports_sound": True,
|
|
||||||
"supports_vibration": True,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for pt_data in project_types:
|
|
||||||
existing = db.query(ProjectType).filter_by(id=pt_data["id"]).first()
|
|
||||||
if not existing:
|
|
||||||
pt = ProjectType(**pt_data)
|
|
||||||
db.add(pt)
|
|
||||||
print(f"✓ Created project type: {pt_data['name']}")
|
|
||||||
else:
|
|
||||||
print(f" Project type already exists: {pt_data['name']}")
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def create_tables():
|
|
||||||
"""Create all tables defined in models."""
|
|
||||||
print("Creating project management tables...")
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
print("✓ Tables created successfully")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 60)
|
|
||||||
print("Terra-View Projects System - Database Initialization")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Create tables
|
|
||||||
create_tables()
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Initialize project types
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
print("Initializing project types...")
|
|
||||||
init_project_types(db)
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("✓ Database initialization complete!")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
print("Next steps:")
|
|
||||||
print(" 1. Restart Terra-View to load new routes")
|
|
||||||
print(" 2. Navigate to /projects to create your first project")
|
|
||||||
print(" 3. Check documentation for API endpoints")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Error during initialization: {e}")
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||