Compare commits
5 Commits
2905a327be
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 56bd3041cf | |||
| 623ef648b7 | |||
| 5ed00bf70e | |||
| 43c804d0c4 | |||
| c1f995b4d3 |
@@ -5,6 +5,97 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.13.2] - 2026-05-30
|
||||||
|
|
||||||
|
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
|
||||||
|
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
|
||||||
|
|
||||||
|
### What mobile users will see after deploy
|
||||||
|
|
||||||
|
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.1] - 2026-05-29
|
||||||
|
|
||||||
|
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
|
||||||
|
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-05-29
|
||||||
|
|
||||||
|
The "SFM integration Phase 1" release. Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
|
||||||
|
|
||||||
|
### Added — Event-detail modal: Chart.js waveform/histogram panels
|
||||||
|
|
||||||
|
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
|
||||||
|
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
|
||||||
|
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
|
||||||
|
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
|
||||||
|
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
|
||||||
|
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
|
||||||
|
|
||||||
|
### Added — Event-detail modal: PDF preview + downloads + review form
|
||||||
|
|
||||||
|
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
|
||||||
|
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
|
||||||
|
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
|
||||||
|
|
||||||
|
### Added — `/admin/events` row click opens the modal
|
||||||
|
|
||||||
|
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
|
||||||
|
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
|
||||||
|
|
||||||
|
### Added — `mic_unit_pref` user setting (Settings → General)
|
||||||
|
|
||||||
|
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
|
||||||
|
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
|
||||||
|
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
|
||||||
|
|
||||||
|
### Fixed — Docker Compose: SFM container can finally read the DB
|
||||||
|
|
||||||
|
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
# Apply the new column to the database — required. Idempotent.
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
|
||||||
|
|
||||||
|
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
|
||||||
|
docker compose build terra-view && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
|
||||||
|
|
||||||
|
### What's NOT in this release
|
||||||
|
|
||||||
|
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.12.1] - 2026-05-20
|
## [0.12.1] - 2026-05-20
|
||||||
|
|
||||||
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
|
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.12.1
|
# Terra-View v0.13.2
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.12.1"
|
VERSION = "0.13.2"
|
||||||
if ENVIRONMENT == "development":
|
if ENVIRONMENT == "development":
|
||||||
_build = os.getenv("BUILD_NUMBER", "0")
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
if _build and _build != "0":
|
if _build and _build != "0":
|
||||||
|
|||||||
@@ -3,10 +3,19 @@
|
|||||||
Database migration: Add mic_unit_pref column to user_preferences.
|
Database migration: Add mic_unit_pref column to user_preferences.
|
||||||
|
|
||||||
Adds a single field controlling the mic channel's unit on the event-
|
Adds a single field controlling the mic channel's unit on the event-
|
||||||
report waveform chart in the SFM event detail modal. "dBL" (default)
|
report waveform chart in the SFM event detail modal. "psi" (default —
|
||||||
or "psi". Peaks and KPI tiles elsewhere are always dBL regardless.
|
matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles
|
||||||
|
elsewhere are always dBL regardless.
|
||||||
|
|
||||||
Idempotent — safe to re-run.
|
History: v0.13.0 originally shipped this with default "dBL", which
|
||||||
|
made the website chart inconsistent with the PDF. v0.13.1 flips the
|
||||||
|
default to "psi" so they match. This migration is idempotent and
|
||||||
|
covers three cases:
|
||||||
|
|
||||||
|
1. Fresh DB without the column — adds it with default 'psi'.
|
||||||
|
2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to
|
||||||
|
'psi' on the assumption no operator deliberately picked 'dBL' yet.
|
||||||
|
3. DB upgraded from later — flip step is a no-op for non-'dBL' values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -32,24 +41,33 @@ def migrate():
|
|||||||
cur.execute("PRAGMA table_info(user_preferences)")
|
cur.execute("PRAGMA table_info(user_preferences)")
|
||||||
existing = {row[1] for row in cur.fetchall()}
|
existing = {row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
if "mic_unit_pref" in existing:
|
if "mic_unit_pref" not in existing:
|
||||||
print("mic_unit_pref already exists — nothing to do.")
|
cur.execute(
|
||||||
conn.close()
|
"ALTER TABLE user_preferences "
|
||||||
return
|
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
|
||||||
|
)
|
||||||
|
# Backfill any rows where the column ended up NULL.
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||||
|
"WHERE mic_unit_pref IS NULL"
|
||||||
|
)
|
||||||
|
print("Added mic_unit_pref column (default 'psi').")
|
||||||
|
else:
|
||||||
|
print("mic_unit_pref column already exists.")
|
||||||
|
|
||||||
|
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
|
||||||
|
# 'dBL' default get bumped to 'psi'. If any operator deliberately
|
||||||
|
# chose 'dBL' through Settings before this migration runs they'd
|
||||||
|
# get reset — acceptable trade-off given the small user base and
|
||||||
|
# the fact the setting is one click to restore.
|
||||||
|
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
|
||||||
|
"WHERE mic_unit_pref = 'dBL'")
|
||||||
|
flipped = cur.rowcount
|
||||||
|
if flipped:
|
||||||
|
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"ALTER TABLE user_preferences "
|
|
||||||
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'dBL'"
|
|
||||||
)
|
|
||||||
# Backfill the single row that should exist (id=1) to the default,
|
|
||||||
# in case the column ends up NULL on existing rows.
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE user_preferences SET mic_unit_pref = 'dBL' "
|
|
||||||
"WHERE mic_unit_pref IS NULL"
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
print("Added mic_unit_pref to user_preferences (default 'dBL').")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+4
-2
@@ -136,8 +136,10 @@ class UserPreferences(Base):
|
|||||||
status_ok_threshold_hours = Column(Integer, default=12)
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
status_pending_threshold_hours = Column(Integer, default=24)
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
# Mic display units on the event-report waveform chart only — peaks
|
# Mic display units on the event-report waveform chart only — peaks
|
||||||
# and KPI tiles elsewhere are always dBL. "dBL" (default) or "psi".
|
# and KPI tiles elsewhere are always dBL. "psi" (default — matches
|
||||||
mic_unit_pref = Column(String, default="dBL")
|
# the PDF report) or "dBL". Default flipped in v0.13.1 after
|
||||||
|
# operator feedback that the chart should mirror the PDF.
|
||||||
|
mic_unit_pref = Column(String, default="psi")
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ 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(),
|
||||||
@@ -148,7 +150,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
"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": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
||||||
}
|
}
|
||||||
call_ins.append(call_in)
|
call_ins.append(call_in)
|
||||||
|
|
||||||
|
|||||||
@@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
# Last seen from emitter
|
# Last seen from emitter
|
||||||
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||||
|
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
|
loc = get_active_location(db, u.id)
|
||||||
return {
|
return {
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"unit_type": u.unit_type,
|
"unit_type": u.unit_type,
|
||||||
"deployed": u.deployed,
|
"deployed": u.deployed,
|
||||||
"out_for_calibration": u.out_for_calibration or False,
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
"note": u.note or "",
|
"note": u.note or "",
|
||||||
"project_id": u.project_id or "",
|
"project_id": (loc or {}).get("project_id") or u.project_id or "",
|
||||||
"address": u.address or u.location or "",
|
"address": (loc or {}).get("address") or "",
|
||||||
"coordinates": u.coordinates or "",
|
"coordinates": (loc or {}).get("coordinates") or "",
|
||||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -85,8 +86,7 @@ async def get_modem_units(
|
|||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.ip_address.ilike(search_term)) |
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
(RosterUnit.hardware_model.ilike(search_term)) |
|
(RosterUnit.hardware_model.ilike(search_term)) |
|
||||||
(RosterUnit.phone_number.ilike(search_term)) |
|
(RosterUnit.phone_number.ilike(search_term))
|
||||||
(RosterUnit.location.ilike(search_term))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
modems = query.order_by(
|
modems = query.order_by(
|
||||||
@@ -128,6 +128,8 @@ async def get_modem_units(
|
|||||||
if filter_status and status != filter_status:
|
if filter_status and status != filter_status:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Inherit location from the paired device's active assignment.
|
||||||
|
loc = get_active_location(db, modem.id) if paired else None
|
||||||
modem_list.append({
|
modem_list.append({
|
||||||
"id": modem.id,
|
"id": modem.id,
|
||||||
"ip_address": modem.ip_address,
|
"ip_address": modem.ip_address,
|
||||||
@@ -135,8 +137,8 @@ async def get_modem_units(
|
|||||||
"hardware_model": modem.hardware_model,
|
"hardware_model": modem.hardware_model,
|
||||||
"deployed": modem.deployed,
|
"deployed": modem.deployed,
|
||||||
"retired": modem.retired,
|
"retired": modem.retired,
|
||||||
"location": modem.location,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"project_id": modem.project_id,
|
"project_id": (loc or {}).get("project_id") or modem.project_id,
|
||||||
"paired_device": paired,
|
"paired_device": paired,
|
||||||
"status": status
|
"status": status
|
||||||
})
|
})
|
||||||
@@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if device:
|
if device:
|
||||||
|
loc = get_active_location(db, device.id)
|
||||||
return {
|
return {
|
||||||
"paired": True,
|
"paired": True,
|
||||||
"device": {
|
"device": {
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": device.project_id,
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||||
"location": device.location or device.address
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,8 +317,6 @@ async def get_pairable_devices(
|
|||||||
query = query.filter(
|
query = query.filter(
|
||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.project_id.ilike(search_term)) |
|
(RosterUnit.project_id.ilike(search_term)) |
|
||||||
(RosterUnit.location.ilike(search_term)) |
|
|
||||||
(RosterUnit.address.ilike(search_term)) |
|
|
||||||
(RosterUnit.note.ilike(search_term))
|
(RosterUnit.note.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,12 +339,13 @@ async def get_pairable_devices(
|
|||||||
if hide_paired and is_paired_to_other:
|
if hide_paired and is_paired_to_other:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
loc = get_active_location(db, device.id)
|
||||||
device_list.append({
|
device_list.append({
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": device.project_id,
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||||
"location": device.location or device.address,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"note": device.note,
|
"note": device.note,
|
||||||
"paired_modem_id": device.deployed_with_modem_id,
|
"paired_modem_id": device.deployed_with_modem_id,
|
||||||
"is_paired_to_this": is_paired_to_this,
|
"is_paired_to_this": is_paired_to_this,
|
||||||
|
|||||||
@@ -1483,11 +1483,13 @@ async def get_available_units(
|
|||||||
).distinct().all()
|
).distinct().all()
|
||||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||||
|
|
||||||
|
# These units have no active assignment by definition, so there's no
|
||||||
|
# current location to show — leave the field empty.
|
||||||
available_units = [
|
available_units = [
|
||||||
{
|
{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"location": unit.address or unit.location,
|
"location": "",
|
||||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||||
"deployed": bool(unit.deployed),
|
"deployed": bool(unit.deployed),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from backend.database import get_db
|
|||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||||
import uuid
|
import uuid
|
||||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -182,9 +183,6 @@ async def add_roster_unit(
|
|||||||
out_for_calibration: str = Form(None),
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
|
||||||
address: str = Form(None),
|
|
||||||
coordinates: str = Form(None),
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -249,9 +247,6 @@ async def add_roster_unit(
|
|||||||
out_for_calibration=out_for_calibration_bool,
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
|
||||||
address=address,
|
|
||||||
coordinates=coordinates,
|
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated=last_cal_date,
|
last_calibrated=last_cal_date,
|
||||||
@@ -273,19 +268,15 @@ async def add_roster_unit(
|
|||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty
|
# Auto-fill data from modem if pairing and fields are empty.
|
||||||
|
# Location/address/coordinates now come from MonitoringLocation via the
|
||||||
|
# active UnitAssignment, so there's nothing to copy from the modem row.
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
if modem:
|
||||||
if not unit.location and modem.location:
|
|
||||||
unit.location = modem.location
|
|
||||||
if not unit.address and modem.address:
|
|
||||||
unit.address = modem.address
|
|
||||||
if not unit.coordinates and modem.coordinates:
|
|
||||||
unit.coordinates = modem.coordinates
|
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -493,6 +484,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type or "seismograph",
|
"device_type": unit.device_type or "seismograph",
|
||||||
@@ -504,9 +497,11 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"active_location": active_loc,
|
||||||
"address": unit.address or "",
|
# Convenience fields so the unit-detail page can read the same shape
|
||||||
"coordinates": unit.coordinates or "",
|
# whether or not there's an active assignment.
|
||||||
|
"address": (active_loc or {}).get("address") or "",
|
||||||
|
"coordinates": (active_loc or {}).get("coordinates") or "",
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
||||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
@@ -538,9 +533,6 @@ async def edit_roster_unit(
|
|||||||
allocated_to_project_id: str = Form(None),
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
|
||||||
address: str = Form(None),
|
|
||||||
coordinates: str = Form(None),
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -565,8 +557,6 @@ async def edit_roster_unit(
|
|||||||
cascade_deployed: str = Form(None),
|
cascade_deployed: str = Form(None),
|
||||||
cascade_retired: str = Form(None),
|
cascade_retired: str = Form(None),
|
||||||
cascade_project: str = Form(None),
|
cascade_project: str = Form(None),
|
||||||
cascade_location: str = Form(None),
|
|
||||||
cascade_coordinates: str = Form(None),
|
|
||||||
cascade_note: str = Form(None),
|
cascade_note: str = Form(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -620,9 +610,6 @@ async def edit_roster_unit(
|
|||||||
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
|
||||||
unit.address = address
|
|
||||||
unit.coordinates = coordinates
|
|
||||||
unit.last_updated = datetime.utcnow()
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -630,20 +617,15 @@ async def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_cal_date
|
unit.next_calibration_due = next_cal_date
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty
|
# Auto-fill data from modem if pairing and fields are empty.
|
||||||
|
# Location/address/coordinates live on MonitoringLocation now, nothing
|
||||||
|
# to copy across roster rows.
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
if modem:
|
||||||
# Only fill if the device field is empty
|
|
||||||
if not unit.location and modem.location:
|
|
||||||
unit.location = modem.location
|
|
||||||
if not unit.address and modem.address:
|
|
||||||
unit.address = modem.address
|
|
||||||
if not unit.coordinates and modem.coordinates:
|
|
||||||
unit.coordinates = modem.coordinates
|
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -769,26 +751,6 @@ async def edit_roster_unit(
|
|||||||
record_history(db, paired_unit.id, "project_change", "project_id",
|
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||||
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
# Cascade address/location
|
|
||||||
if cascade_location in ['true', 'True', '1', 'yes']:
|
|
||||||
old_paired_address = paired_unit.address
|
|
||||||
old_paired_location = paired_unit.location
|
|
||||||
paired_unit.address = address
|
|
||||||
paired_unit.location = location
|
|
||||||
paired_unit.last_updated = datetime.utcnow()
|
|
||||||
if old_paired_address != address:
|
|
||||||
record_history(db, paired_unit.id, "address_change", "address",
|
|
||||||
old_paired_address or "", address or "", f"cascade from {unit_id}")
|
|
||||||
|
|
||||||
# Cascade coordinates
|
|
||||||
if cascade_coordinates in ['true', 'True', '1', 'yes']:
|
|
||||||
old_paired_coords = paired_unit.coordinates
|
|
||||||
paired_unit.coordinates = coordinates
|
|
||||||
paired_unit.last_updated = datetime.utcnow()
|
|
||||||
if old_paired_coords != coordinates:
|
|
||||||
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
|
|
||||||
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
|
|
||||||
|
|
||||||
# Cascade note
|
# Cascade note
|
||||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||||
old_paired_note = paired_unit.note
|
old_paired_note = paired_unit.note
|
||||||
@@ -1011,9 +973,8 @@ async def import_csv(
|
|||||||
- retired: Boolean
|
- retired: Boolean
|
||||||
- note: Notes about the unit
|
- note: Notes about the unit
|
||||||
- project_id: Project identifier
|
- project_id: Project identifier
|
||||||
- location: Location description
|
(Location / address / coordinates are not roster fields anymore — they
|
||||||
- address: Street address
|
live on the MonitoringLocation a unit is assigned to.)
|
||||||
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
|
||||||
|
|
||||||
Seismograph-specific:
|
Seismograph-specific:
|
||||||
- last_calibrated: Date (YYYY-MM-DD)
|
- last_calibrated: Date (YYYY-MM-DD)
|
||||||
@@ -1126,9 +1087,6 @@ async def import_csv(
|
|||||||
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
||||||
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
||||||
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
||||||
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
|
|
||||||
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
|
|
||||||
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
|
|
||||||
existing_unit.last_updated = datetime.utcnow()
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -1194,9 +1152,6 @@ async def import_csv(
|
|||||||
retired=_parse_bool(row.get('retired', '')),
|
retired=_parse_bool(row.get('retired', '')),
|
||||||
note=_get_csv_value(row, 'note', ''),
|
note=_get_csv_value(row, 'note', ''),
|
||||||
project_id=_get_csv_value(row, 'project_id'),
|
project_id=_get_csv_value(row, 'project_id'),
|
||||||
location=_get_csv_value(row, 'location'),
|
|
||||||
address=_get_csv_value(row, 'address'),
|
|
||||||
coordinates=_get_csv_value(row, 'coordinates'),
|
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
||||||
last_calibrated=last_cal,
|
last_calibrated=last_cal,
|
||||||
|
|||||||
+13
-11
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
from backend.services.unit_location import bulk_active_locations
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -21,11 +22,14 @@ 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
|
# Create CSV in memory. Location lives on MonitoringLocation now, so
|
||||||
|
# we don't export legacy address/coordinates/location columns here —
|
||||||
|
# round-trip CSV editing would otherwise look like it edits unit
|
||||||
|
# location, when it can't.
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
||||||
'note', 'project_id', 'location', 'address', 'coordinates',
|
'note', 'project_id',
|
||||||
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||||
'ip_address', 'phone_number', 'hardware_model'
|
'ip_address', 'phone_number', 'hardware_model'
|
||||||
]
|
]
|
||||||
@@ -42,9 +46,6 @@ def export_roster_csv(db: Session = Depends(get_db)):
|
|||||||
'retired': 'true' if unit.retired else 'false',
|
'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 '',
|
||||||
@@ -82,6 +83,7 @@ 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,
|
||||||
@@ -90,10 +92,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": unit.project_id or "",
|
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"address": (active_locs.get(unit.id) or {}).get("address") or "",
|
||||||
"address": unit.address or "",
|
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
|
||||||
"coordinates": unit.coordinates or "",
|
"location_name": (active_locs.get(unit.id) or {}).get("name") or "",
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
"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 "",
|
||||||
@@ -294,7 +296,7 @@ def get_preferences(db: Session = Depends(get_db)):
|
|||||||
"calibration_warning_days": prefs.calibration_warning_days,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +338,7 @@ def update_preferences(
|
|||||||
"calibration_warning_days": prefs.calibration_warning_days,
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import os
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,13 +59,14 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||||
|
|
||||||
|
loc = get_active_location(db, unit_id)
|
||||||
return {
|
return {
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"device_type": "slm",
|
"device_type": "slm",
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"model": unit.slm_model or "NL-43",
|
"model": unit.slm_model or "NL-43",
|
||||||
"location": unit.address or unit.location,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"coordinates": unit.coordinates,
|
"coordinates": (loc or {}).get("coordinates") or "",
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"status": status_data,
|
"status": status_data,
|
||||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
|||||||
+12
-16
@@ -5,6 +5,7 @@ from typing import Dict, Any, Optional
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
@@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"])
|
|||||||
@router.get("/unit/{unit_id}")
|
@router.get("/unit/{unit_id}")
|
||||||
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Returns detailed data for a single unit.
|
Returns detailed data for a single unit, including its active deployment
|
||||||
|
location (or None if benched / unassigned).
|
||||||
"""
|
"""
|
||||||
snapshot = emit_status_snapshot()
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
@@ -21,17 +23,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
unit_data = snapshot["units"][unit_id]
|
unit_data = snapshot["units"][unit_id]
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
# Mock coordinates for now (will be replaced with real data)
|
|
||||||
mock_coords = {
|
|
||||||
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
|
||||||
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
|
||||||
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
|
||||||
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
|
||||||
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
|
||||||
}
|
|
||||||
|
|
||||||
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
@@ -41,7 +33,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"last_file": unit_data.get("fname", ""),
|
"last_file": unit_data.get("fname", ""),
|
||||||
"deployed": unit_data["deployed"],
|
"deployed": unit_data["deployed"],
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"coordinates": coords
|
"active_location": active_loc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,12 +41,16 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Get unit data directly from the roster (for settings/configuration).
|
Get unit data directly from the roster (for settings/configuration).
|
||||||
|
Address/coordinates come from the active MonitoringLocation, not the
|
||||||
|
roster row.
|
||||||
"""
|
"""
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
@@ -62,9 +58,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"location": unit.location,
|
"active_location": active_loc,
|
||||||
"address": unit.address,
|
"address": (active_loc or {}).get("address") or "",
|
||||||
"coordinates": unit.coordinates,
|
"coordinates": (active_loc or {}).get("coordinates") or "",
|
||||||
"slm_host": unit.slm_host,
|
"slm_host": unit.slm_host,
|
||||||
"slm_tcp_port": unit.slm_tcp_port,
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
"slm_ftp_port": unit.slm_ftp_port,
|
"slm_ftp_port": unit.slm_ftp_port,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.database import get_db_session
|
from backend.database import get_db_session
|
||||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
from backend.services.unit_location import bulk_active_locations
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,6 +138,10 @@ def emit_status_snapshot():
|
|||||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
|
|
||||||
|
# Active-assignment location lookup for all roster units (direct only;
|
||||||
|
# modems inherit from their paired device below in the derive loop).
|
||||||
|
active_locs = bulk_active_locations(db, list(roster.values()))
|
||||||
|
|
||||||
# SFM event-forwards are now the primary "last seen" signal for
|
# SFM event-forwards are now the primary "last seen" signal for
|
||||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||||
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
||||||
@@ -225,10 +230,13 @@ def emit_status_snapshot():
|
|||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
# Location for mapping
|
# Location for mapping — sourced from active UnitAssignment
|
||||||
"location": r.location or "",
|
# → MonitoringLocation. Empty for benched / unassigned.
|
||||||
"address": r.address or "",
|
"address": (active_locs.get(unit_id) or {}).get("address") or "",
|
||||||
"coordinates": r.coordinates or "",
|
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "",
|
||||||
|
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
|
||||||
|
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
|
||||||
|
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Add unexpected emitter-only units ---
|
# --- Add unexpected emitter-only units ---
|
||||||
@@ -267,10 +275,12 @@ def emit_status_snapshot():
|
|||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
# Location fields
|
# Location fields — unknown units have no assignment
|
||||||
"location": "",
|
|
||||||
"address": "",
|
"address": "",
|
||||||
"coordinates": "",
|
"coordinates": "",
|
||||||
|
"location_name": "",
|
||||||
|
"project_id": "",
|
||||||
|
"location_id": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Derive modem status from paired devices ---
|
# --- Derive modem status from paired devices ---
|
||||||
@@ -301,6 +311,11 @@ def emit_status_snapshot():
|
|||||||
unit_data["last"] = paired_unit.get("last")
|
unit_data["last"] = paired_unit.get("last")
|
||||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||||
unit_data["derived_from"] = paired_unit_id
|
unit_data["derived_from"] = paired_unit_id
|
||||||
|
# Inherit deployment location too — modems don't carry
|
||||||
|
# their own UnitAssignment.
|
||||||
|
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
|
||||||
|
if not unit_data.get(k):
|
||||||
|
unit_data[k] = paired_unit.get(k, "")
|
||||||
|
|
||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Active-assignment location resolution for roster units.
|
||||||
|
|
||||||
|
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
|
||||||
|
The current source of truth for "where is this unit deployed right now" is the
|
||||||
|
active `UnitAssignment` (assigned_until IS NULL) pointing at a
|
||||||
|
`MonitoringLocation`, which carries the canonical address/coordinates/name.
|
||||||
|
|
||||||
|
Modems don't get their own `UnitAssignment` — they're paired with a
|
||||||
|
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
|
||||||
|
location of its paired device's active assignment.
|
||||||
|
|
||||||
|
Returned dict shape (or None if no active assignment resolvable):
|
||||||
|
{
|
||||||
|
"location_id": "uuid",
|
||||||
|
"project_id": "uuid",
|
||||||
|
"name": "NRL-001",
|
||||||
|
"address": "123 Main St" | None,
|
||||||
|
"coordinates": "34.0522,-118.2437" | None,
|
||||||
|
"via_paired_unit_id": "BE1234" | None, # set only for modems
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
|
||||||
|
return {
|
||||||
|
"location_id": loc.id,
|
||||||
|
"project_id": loc.project_id,
|
||||||
|
"name": loc.name,
|
||||||
|
"address": loc.address or None,
|
||||||
|
"coordinates": loc.coordinates or None,
|
||||||
|
"via_paired_unit_id": via_paired_unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
|
||||||
|
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
|
||||||
|
row = (
|
||||||
|
db.query(MonitoringLocation)
|
||||||
|
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
|
||||||
|
.filter(
|
||||||
|
UnitAssignment.unit_id == unit_id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
)
|
||||||
|
.order_by(UnitAssignment.assigned_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Resolve the active deployment location for a unit.
|
||||||
|
|
||||||
|
Seismographs / SLMs: their own active UnitAssignment.
|
||||||
|
Modems: follow `deployed_with_unit_id` to the paired device's active
|
||||||
|
assignment (modems don't carry their own assignment).
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if unit is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if (unit.device_type or "seismograph") == "modem":
|
||||||
|
paired_id = unit.deployed_with_unit_id
|
||||||
|
if not paired_id:
|
||||||
|
return None
|
||||||
|
loc = _active_location_for_unit_id(db, paired_id)
|
||||||
|
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
|
||||||
|
|
||||||
|
loc = _active_location_for_unit_id(db, unit_id)
|
||||||
|
return _serialize(loc) if loc else None
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Resolve active locations for many units in two queries. Use this from
|
||||||
|
snapshot-style loops to avoid N+1 lookups.
|
||||||
|
|
||||||
|
Returns {unit_id: <serialized location dict>} — only populated for units
|
||||||
|
that resolve to an active assignment. Modems are resolved by walking
|
||||||
|
`deployed_with_unit_id` to the paired device's entry in the same map.
|
||||||
|
"""
|
||||||
|
if not units:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
direct_unit_ids = [
|
||||||
|
u.id for u in units
|
||||||
|
if (u.device_type or "seismograph") != "modem"
|
||||||
|
]
|
||||||
|
|
||||||
|
direct: dict[str, MonitoringLocation] = {}
|
||||||
|
if direct_unit_ids:
|
||||||
|
rows = (
|
||||||
|
db.query(UnitAssignment.unit_id, MonitoringLocation)
|
||||||
|
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
|
||||||
|
.filter(
|
||||||
|
UnitAssignment.unit_id.in_(direct_unit_ids),
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
)
|
||||||
|
.order_by(UnitAssignment.assigned_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# First row wins per unit_id (most recent assigned_at).
|
||||||
|
for unit_id, loc in rows:
|
||||||
|
direct.setdefault(unit_id, loc)
|
||||||
|
|
||||||
|
out: dict[str, dict] = {
|
||||||
|
uid: _serialize(loc) for uid, loc in direct.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modems inherit from paired device.
|
||||||
|
for u in units:
|
||||||
|
if (u.device_type or "seismograph") != "modem":
|
||||||
|
continue
|
||||||
|
paired_id = u.deployed_with_unit_id
|
||||||
|
if paired_id and paired_id in direct:
|
||||||
|
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
|
||||||
|
|
||||||
|
return out
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
const MIC_DBL_FLOOR = 60;
|
const MIC_DBL_FLOOR = 60;
|
||||||
|
|
||||||
let _charts = {}; // ch → Chart instance
|
let _charts = {}; // ch → Chart instance
|
||||||
let _micUnitPref = 'dBL'; // refreshed via fetch on first chart render
|
let _micUnitPref = 'psi'; // refreshed via fetch on first chart render
|
||||||
let _micUnitPrefLoaded = false; // one-shot fetch guard
|
let _micUnitPrefLoaded = false; // one-shot fetch guard
|
||||||
|
|
||||||
function _esc(s) {
|
function _esc(s) {
|
||||||
@@ -294,10 +294,10 @@
|
|||||||
const r = await fetch('/api/settings/preferences');
|
const r = await fetch('/api/settings/preferences');
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const prefs = await r.json();
|
const prefs = await r.json();
|
||||||
_micUnitPref = prefs.mic_unit_pref === 'psi' ? 'psi' : 'dBL';
|
_micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Network error → silent fall back to default 'dBL'.
|
// Network error → silent fall back to default 'psi'.
|
||||||
}
|
}
|
||||||
_micUnitPrefLoaded = true;
|
_micUnitPrefLoaded = true;
|
||||||
return _micUnitPref;
|
return _micUnitPref;
|
||||||
@@ -647,7 +647,9 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
||||||
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
||||||
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span></div>
|
<div title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that).">
|
||||||
|
<span class="text-gray-500">Time received</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span>
|
||||||
|
</div>
|
||||||
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -1,18 +1,27 @@
|
|||||||
/* Service Worker for Seismo Fleet Manager PWA */
|
/* Service Worker for Seismo Fleet Manager PWA */
|
||||||
/* Network-first strategy with cache fallback for real-time data */
|
/* Network-first strategy with cache fallback for real-time data */
|
||||||
|
|
||||||
const CACHE_VERSION = 'v1';
|
// IMPORTANT: bump this on every release that touches a precached or
|
||||||
|
// runtime-cached static asset (event-modal.js, mobile.js, style.css,
|
||||||
|
// templates served at /, etc.). The activate handler deletes any cache
|
||||||
|
// not matching CACHE_VERSION, so old SW caches get evicted and mobile
|
||||||
|
// PWA users actually receive the new bundles instead of being stuck on
|
||||||
|
// the pre-bump version. Convention: keep it in sync with the Terra-View
|
||||||
|
// version string in backend/main.py.
|
||||||
|
const CACHE_VERSION = 'v0.13.2';
|
||||||
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||||
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||||
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||||
|
|
||||||
// Files to precache (critical app shell)
|
// Files to precache (critical app shell). event-modal.js is included
|
||||||
|
// so its cache lifecycle is tied to the SW version bump explicitly.
|
||||||
const STATIC_FILES = [
|
const STATIC_FILES = [
|
||||||
'/',
|
'/',
|
||||||
'/static/style.css',
|
'/static/style.css',
|
||||||
'/static/mobile.css',
|
'/static/mobile.css',
|
||||||
'/static/mobile.js',
|
'/static/mobile.js',
|
||||||
'/static/offline-db.js',
|
'/static/offline-db.js',
|
||||||
|
'/static/event-modal.js',
|
||||||
'/static/manifest.json',
|
'/static/manifest.json',
|
||||||
'https://cdn.tailwindcss.com',
|
'https://cdn.tailwindcss.com',
|
||||||
'https://unpkg.com/htmx.org@1.9.10',
|
'https://unpkg.com/htmx.org@1.9.10',
|
||||||
|
|||||||
+86
-51
@@ -150,46 +150,55 @@ setInterval(_refreshPendingDeployBanner, 30000);
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 card-content" id="fleet-summary-content">
|
<div class="space-y-4 card-content" id="fleet-summary-content">
|
||||||
<div class="flex justify-between items-center">
|
<!-- Seismographs -->
|
||||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
<div>
|
||||||
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
|
||||||
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
|
||||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
|
||||||
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
|
||||||
<div class="flex justify-between items-center mb-1">
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||||
</div>
|
</div>
|
||||||
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sound Level Meters -->
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||||
|
</div>
|
||||||
|
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
|
||||||
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
@@ -628,9 +637,14 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
fleetMarkers = [];
|
fleetMarkers = [];
|
||||||
|
|
||||||
// Get deployed units with coordinates that pass the filter
|
// Get deployed units with coordinates that pass the filter.
|
||||||
|
// Modems are not plotted — they inherit the paired device's location,
|
||||||
|
// which would just stack a duplicate marker on the same pin.
|
||||||
const deployedUnits = Object.entries(allUnits || {})
|
const deployedUnits = Object.entries(allUnits || {})
|
||||||
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
|
.filter(([_, u]) => u.deployed
|
||||||
|
&& u.coordinates
|
||||||
|
&& (u.device_type || 'seismograph') !== 'modem'
|
||||||
|
&& unitPassesFilter(u));
|
||||||
|
|
||||||
if (deployedUnits.length === 0) {
|
if (deployedUnits.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -672,10 +686,12 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
// Popup with device type
|
// Popup with device type
|
||||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
const deviceLabel = getDeviceTypeLabel(deviceType);
|
||||||
|
|
||||||
|
const locName = unit.location_name || '';
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<h3 class="font-bold text-lg">${id}</h3>
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
||||||
|
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
|
||||||
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||||
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
||||||
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
||||||
@@ -783,32 +799,51 @@ function updateDashboard(event) {
|
|||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Fleet summary numbers (always unfiltered) =====
|
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
|
||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
// Deployed = unit has an active UnitAssignment (location_id set by
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
// the snapshot helper). Benched = no active assignment.
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
// Retired, out-for-calibration, and roster-unknown units (emitters
|
||||||
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
// not in the roster) are excluded from totals.
|
||||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
const counts = {
|
||||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
seismograph: { total: 0, deployed: 0, benched: 0 },
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
sound_level_meter: { total: 0, deployed: 0, benched: 0 },
|
||||||
|
};
|
||||||
|
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
|
||||||
|
const unknownIds = new Set(Object.keys(data.unknown || {}));
|
||||||
|
|
||||||
// ===== Device type counts (always unfiltered) =====
|
Object.entries(data.units || {}).forEach(([uid, unit]) => {
|
||||||
let seismoCount = 0;
|
if (unit.retired || unit.out_for_calibration) return;
|
||||||
let slmCount = 0;
|
if (unknownIds.has(uid)) return;
|
||||||
let modemCount = 0;
|
const dt = unit.device_type || 'seismograph';
|
||||||
Object.values(data.units || {}).forEach(unit => {
|
const bucket = counts[dt];
|
||||||
if (unit.retired) return; // Don't count retired units
|
if (!bucket) return; // skip modems and anything else
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
|
||||||
if (deviceType === 'seismograph') {
|
bucket.total++;
|
||||||
seismoCount++;
|
if (unit.location_id) {
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
bucket.deployed++;
|
||||||
slmCount++;
|
} else {
|
||||||
} else if (deviceType === 'modem') {
|
bucket.benched++;
|
||||||
modemCount++;
|
}
|
||||||
|
|
||||||
|
// Status tally only for seismographs + SLMs that are actually
|
||||||
|
// deployed (assigned). Mirrors the per-device buckets so the
|
||||||
|
// sum matches.
|
||||||
|
if (unit.location_id) {
|
||||||
|
if (unit.status === 'OK') monitoredOk++;
|
||||||
|
else if (unit.status === 'Pending') monitoredPending++;
|
||||||
|
else if (unit.status === 'Missing') monitoredMissing++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('seismo-count').textContent = seismoCount;
|
|
||||||
document.getElementById('slm-count').textContent = slmCount;
|
document.getElementById('seismo-count').textContent = counts.seismograph.total;
|
||||||
|
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
|
||||||
|
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
|
||||||
|
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
|
||||||
|
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
|
||||||
|
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
|
||||||
|
document.getElementById('status-ok').textContent = monitoredOk;
|
||||||
|
document.getElementById('status-pending').textContent = monitoredPending;
|
||||||
|
document.getElementById('status-missing').textContent = monitoredMissing;
|
||||||
|
|
||||||
// ===== Apply filters and render map + alerts =====
|
// ===== Apply filters and render map + alerts =====
|
||||||
renderFilteredDashboard(data);
|
renderFilteredDashboard(data);
|
||||||
|
|||||||
@@ -130,8 +130,8 @@
|
|||||||
</label>
|
</label>
|
||||||
<select id="mic-unit-pref"
|
<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">
|
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||||
<option value="dBL" selected>dB(L) — sound pressure level</option>
|
<option value="psi" selected>psi — raw pressure (matches PDF report)</option>
|
||||||
<option value="psi">psi — raw pressure</option>
|
<option value="dBL">dB(L) — sound pressure level</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<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.
|
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
|
||||||
@@ -787,7 +787,7 @@ async function loadPreferences() {
|
|||||||
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
|
// Load event-report mic units
|
||||||
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'dBL';
|
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'psi';
|
||||||
|
|
||||||
// Load status thresholds
|
// 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;
|
||||||
|
|||||||
+47
-35
@@ -129,6 +129,15 @@
|
|||||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
|
||||||
|
<p id="viewLocationContainer" class="mt-1">
|
||||||
|
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
||||||
|
<span id="viewLocationText">--</span>
|
||||||
|
</a>
|
||||||
|
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
@@ -639,18 +648,12 @@
|
|||||||
{% include "partials/project_picker.html" with context %}
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address -->
|
<!-- Address / coordinates are managed on the project's
|
||||||
<div>
|
MonitoringLocation, not the unit itself. Edit them on
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
the project page. -->
|
||||||
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
|
<div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
Address & coordinates are set on the deployment location.
|
||||||
</div>
|
Open the project to edit them.
|
||||||
|
|
||||||
<!-- Coordinates -->
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
|
||||||
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -848,16 +851,6 @@
|
|||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
@@ -1168,8 +1161,28 @@ function populateViewMode() {
|
|||||||
if (projectLink) projectLink.classList.add('hidden');
|
if (projectLink) projectLink.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
// Deployment Location — comes from the active UnitAssignment →
|
||||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
// MonitoringLocation. Show project link if present, otherwise
|
||||||
|
// "Not deployed" placeholder.
|
||||||
|
const locLink = document.getElementById('viewLocationLink');
|
||||||
|
const locText = document.getElementById('viewLocationText');
|
||||||
|
const locNoLink = document.getElementById('viewLocationNoLink');
|
||||||
|
const activeLoc = currentUnit.active_location;
|
||||||
|
if (activeLoc && activeLoc.location_id) {
|
||||||
|
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
|
||||||
|
if (locLink) {
|
||||||
|
locLink.href = `/projects/${activeLoc.project_id}`;
|
||||||
|
locLink.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (locNoLink) locNoLink.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
if (locLink) locLink.classList.add('hidden');
|
||||||
|
if (locNoLink) locNoLink.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address / coordinates also come from the active assignment.
|
||||||
|
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
|
||||||
|
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||||
@@ -1327,8 +1340,6 @@ function populateEditForm() {
|
|||||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('address').value = currentUnit.address || '';
|
|
||||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
||||||
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||||
@@ -1609,8 +1620,13 @@ function initUnitMap() {
|
|||||||
// Update marker (can be called multiple times)
|
// Update marker (can be called multiple times)
|
||||||
updateMapMarker(lat, lon);
|
updateMapMarker(lat, lon);
|
||||||
|
|
||||||
// Update location text
|
// Update location text — prefer the assignment's location name, fall
|
||||||
|
// back to address, then coordinates.
|
||||||
const locationParts = [];
|
const locationParts = [];
|
||||||
|
const loc = currentUnit.active_location;
|
||||||
|
if (loc && loc.name) {
|
||||||
|
locationParts.push(loc.name);
|
||||||
|
}
|
||||||
if (currentUnit.address) {
|
if (currentUnit.address) {
|
||||||
locationParts.push(currentUnit.address);
|
locationParts.push(currentUnit.address);
|
||||||
}
|
}
|
||||||
@@ -1724,13 +1740,12 @@ async function uploadPhoto(file) {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// Show success message with metadata info
|
// Show success message with metadata info. Location is on the
|
||||||
|
// assignment's MonitoringLocation now, so we just surface what GPS
|
||||||
|
// came in — the backend no longer mutates the unit row.
|
||||||
let message = 'Photo uploaded successfully!';
|
let message = 'Photo uploaded successfully!';
|
||||||
if (result.metadata && result.metadata.coordinates) {
|
if (result.metadata && result.metadata.coordinates) {
|
||||||
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||||
if (result.coordinates_updated) {
|
|
||||||
message += ' (Unit coordinates updated automatically)';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
message += ' No GPS data found in photo.';
|
message += ' No GPS data found in photo.';
|
||||||
}
|
}
|
||||||
@@ -1738,11 +1753,8 @@ async function uploadPhoto(file) {
|
|||||||
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
statusDiv.textContent = message;
|
statusDiv.textContent = message;
|
||||||
|
|
||||||
// Reload photos and unit data
|
// Reload photos
|
||||||
await loadPhotos();
|
await loadPhotos();
|
||||||
if (result.coordinates_updated) {
|
|
||||||
await loadUnitData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide status after 5 seconds
|
// Hide status after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user