Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2086d6bb | |||
| d0685baed5 | |||
| 275a168046 | |||
| f4fd1c943d |
-134
@@ -5,140 +5,6 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
SLM live monitoring — fan-out feed + cache-first reads. Targets **0.14.0**. The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed. This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand. Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
|
|
||||||
- **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0.
|
|
||||||
- **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
|
|
||||||
- **Live Measurements panel auto-populates from cache.** Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit. Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale). Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
|
|
||||||
- **Refresh buttons** — one per device-list row, one in the panel header. On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
|
|
||||||
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Dashboard device list + command center read SLMM's cache, not the device.** `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load. This stops dashboard loads from stealing the device's single connection from the live monitor. The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
|
|
||||||
- **Device-list freshness reflects real monitoring.** The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates. The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
|
|
||||||
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.** `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`. The deploy POST had already succeeded, so the operator saw both the green success **and** a red error. Both call sites now guard on the element existing (`slm_settings_modal.html`).
|
|
||||||
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
|
|
||||||
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
|
|
||||||
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
|
|
||||||
Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
|
|
||||||
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
|
|
||||||
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
|
|
||||||
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py
|
|
||||||
|
|
||||||
# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
|
|
||||||
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
|
|
||||||
```
|
|
||||||
|
|
||||||
The two builds must ship **together**. Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Client portal *(new — read-only client-facing view)*
|
|
||||||
|
|
||||||
A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
|
|
||||||
locations, live. Built inside Terra-View (no new service), reusing the cached
|
|
||||||
SLMM feed; every route resolves the client through one swappable
|
|
||||||
`get_current_client` gate, so the interim magic/open-link auth can be replaced
|
|
||||||
(M4) without touching routes or templates. Strictly read-only — no device control.
|
|
||||||
|
|
||||||
#### Added
|
|
||||||
|
|
||||||
- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a
|
|
||||||
`Project.client_id` FK. A signed (HMAC) session cookie carries the access-token
|
|
||||||
id, re-validated against the DB each request (revoke kills live sessions, with
|
|
||||||
server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a
|
|
||||||
dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
|
|
||||||
- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
|
|
||||||
instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
|
|
||||||
the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream
|
|
||||||
**auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
|
|
||||||
cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
|
|
||||||
- **Locations overview.** Live status map (level-colored dots, dark/light CARTO
|
|
||||||
tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the
|
|
||||||
headline metric.
|
|
||||||
- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail
|
|
||||||
page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
|
|
||||||
read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
|
|
||||||
in the portal; enabling a rule pins that device's monitor on so alerts evaluate
|
|
||||||
round-the-clock.
|
|
||||||
- **Operator sharing tools.** A **"View client portal"** preview button and a
|
|
||||||
**"Copy client link"** modal (mint / list / revoke magic links) on the project
|
|
||||||
page, plus a `backend/portal_admin.py` CLI.
|
|
||||||
- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI +
|
|
||||||
IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
|
|
||||||
**light/dark toggle** (light default, persisted, no-flash).
|
|
||||||
|
|
||||||
#### Security
|
|
||||||
|
|
||||||
- All scoping enforced server-side (404-not-403, no existence leak); client
|
|
||||||
endpoints return **scrubbed** projections (no device-health/internal ids); WS
|
|
||||||
frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
|
|
||||||
Pre-merge code review hardened cookie expiry, open-links default, and the slug
|
|
||||||
collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
|
|
||||||
tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".
|
|
||||||
|
|
||||||
#### Upgrade Notes
|
|
||||||
|
|
||||||
- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
|
|
||||||
(adds `projects.client_id`; the `clients` / `client_access_tokens` tables
|
|
||||||
auto-create).
|
|
||||||
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
|
|
||||||
and keep **`PORTAL_OPEN_LINKS=false`** there.
|
|
||||||
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
|
|
||||||
cooldown + keepalive coupling) — same build pairing as above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.13.3] - 2026-06-05
|
|
||||||
|
|
||||||
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Calibration sync service** (`backend/services/calibration_sync.py`). Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`. Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
|
|
||||||
- **Conflict rule: events-as-truth, manual wins when newer.** Three outcomes per unit:
|
|
||||||
- `already_in_sync` — stored date already matches the event's calibration date.
|
|
||||||
- `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved. Only a future event can supersede it.
|
|
||||||
- `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
|
|
||||||
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`). Started in `main.py`'s startup hook, stopped on shutdown. Does not run on boot — first sync after a server start fires at the next 03:15.
|
|
||||||
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`. Powers the Settings button.
|
|
||||||
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows. Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
|
|
||||||
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab). Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
|
|
||||||
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist. Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**. The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it. Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
|
|
||||||
No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist. Rebuild only:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/terra-view
|
|
||||||
docker compose build terra-view && docker compose up -d terra-view
|
|
||||||
```
|
|
||||||
|
|
||||||
After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.13.2] - 2026-05-30
|
## [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.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.13.3
|
# 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
|
||||||
|
|||||||
+3
-109
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.13.3"
|
VERSION = "0.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":
|
||||||
@@ -66,21 +66,6 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
|||||||
# Use shared templates configuration with timezone filters
|
# Use shared templates configuration with timezone filters
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
# Client-portal auth: an unauthenticated portal request renders the access page
|
|
||||||
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
|
|
||||||
# portal route can simply Depends(get_current_client).
|
|
||||||
from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS
|
|
||||||
|
|
||||||
@app.exception_handler(PortalAuthError)
|
|
||||||
async def portal_auth_handler(request: Request, exc: PortalAuthError):
|
|
||||||
if request.url.path.startswith("/portal/api"):
|
|
||||||
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"portal/access_required.html",
|
|
||||||
{"request": request, "reason": "required"},
|
|
||||||
status_code=401,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add custom context processor to inject environment variable into all templates
|
# Add custom context processor to inject environment variable into all templates
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def add_environment_to_context(request: Request, call_next):
|
async def add_environment_to_context(request: Request, call_next):
|
||||||
@@ -112,10 +97,6 @@ app.include_router(slmm.router)
|
|||||||
app.include_router(slm_ui.router)
|
app.include_router(slm_ui.router)
|
||||||
app.include_router(slm_dashboard.router)
|
app.include_router(slm_dashboard.router)
|
||||||
app.include_router(seismo_dashboard.router)
|
app.include_router(seismo_dashboard.router)
|
||||||
|
|
||||||
# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md
|
|
||||||
from backend.routers import portal
|
|
||||||
app.include_router(portal.router)
|
|
||||||
app.include_router(sfm.router)
|
app.include_router(sfm.router)
|
||||||
app.include_router(modem_dashboard.router)
|
app.include_router(modem_dashboard.router)
|
||||||
|
|
||||||
@@ -163,14 +144,9 @@ app.include_router(fleet_calendar.router)
|
|||||||
from backend.routers import deployments
|
from backend.routers import deployments
|
||||||
app.include_router(deployments.router)
|
app.include_router(deployments.router)
|
||||||
|
|
||||||
# Calibration sync router (SFM-driven cal date updates)
|
|
||||||
from backend.routers import calibration
|
|
||||||
app.include_router(calibration.router)
|
|
||||||
|
|
||||||
# Start scheduler service and device status monitor on application startup
|
# Start scheduler service and device status monitor on application startup
|
||||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||||
from backend.services.calibration_sync import get_calibration_sync_scheduler
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
@@ -183,10 +159,6 @@ async def startup_event():
|
|||||||
await start_device_status_monitor()
|
await start_device_status_monitor()
|
||||||
logger.info("Device status monitor started")
|
logger.info("Device status monitor started")
|
||||||
|
|
||||||
logger.info("Starting calibration sync scheduler...")
|
|
||||||
get_calibration_sync_scheduler().start()
|
|
||||||
logger.info("Calibration sync scheduler started")
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def shutdown_event():
|
def shutdown_event():
|
||||||
"""Clean up services on app shutdown"""
|
"""Clean up services on app shutdown"""
|
||||||
@@ -198,10 +170,6 @@ def shutdown_event():
|
|||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
logger.info("Scheduler service stopped")
|
logger.info("Scheduler service stopped")
|
||||||
|
|
||||||
logger.info("Stopping calibration sync scheduler...")
|
|
||||||
get_calibration_sync_scheduler().stop()
|
|
||||||
logger.info("Calibration sync scheduler stopped")
|
|
||||||
|
|
||||||
|
|
||||||
# Legacy routes from the original backend
|
# Legacy routes from the original backend
|
||||||
from backend import routes as legacy_routes
|
from backend import routes as legacy_routes
|
||||||
@@ -409,84 +377,10 @@ async def project_detail_page(request: Request, project_id: str):
|
|||||||
"""Project detail dashboard"""
|
"""Project detail dashboard"""
|
||||||
return templates.TemplateResponse("projects/detail.html", {
|
return templates.TemplateResponse("projects/detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id
|
||||||
"portal_open_links": PORTAL_OPEN_LINKS,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/portal-preview")
|
|
||||||
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
|
|
||||||
"""Operator testing shortcut: log into the client portal scoped to this project
|
|
||||||
(auto-provisioning a client/link if needed), no CLI. Lives under /projects (not
|
|
||||||
/portal), so a public proxy that exposes only /portal/* won't expose this."""
|
|
||||||
from backend.models import Project
|
|
||||||
from backend.portal_auth import (
|
|
||||||
provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
|
|
||||||
)
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
if not project:
|
|
||||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
|
||||||
token_id = provision_preview_session(project, db)
|
|
||||||
resp = RedirectResponse(url="/portal", status_code=303)
|
|
||||||
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
|
||||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/projects/{project_id}/portal-link")
|
|
||||||
async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)):
|
|
||||||
"""Mint a fresh shareable client link for this project's client. Returns the
|
|
||||||
full /portal/enter/<token> URL (shown once). Operator-only (internal app)."""
|
|
||||||
from backend.models import Project
|
|
||||||
from backend.portal_auth import ensure_project_client, mint_link_token
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
if not project:
|
|
||||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
|
||||||
client = ensure_project_client(project, db)
|
|
||||||
raw = mint_link_token(client, db, label="shared link")
|
|
||||||
url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}"
|
|
||||||
return {"url": url, "client_name": client.name}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/portal-links")
|
|
||||||
async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)):
|
|
||||||
"""List active (non-revoked) shareable links for this project's client."""
|
|
||||||
from backend.models import Project, ClientAccessToken, Client
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
if not project or not project.client_id:
|
|
||||||
return {"client_name": None, "links": []}
|
|
||||||
client = db.query(Client).filter_by(id=project.client_id).first()
|
|
||||||
toks = (db.query(ClientAccessToken)
|
|
||||||
.filter_by(client_id=project.client_id, revoked_at=None)
|
|
||||||
.order_by(ClientAccessToken.created_at.desc()).all())
|
|
||||||
return {
|
|
||||||
"client_name": client.name if client else None,
|
|
||||||
"links": [{
|
|
||||||
"id": t.id, "label": t.label,
|
|
||||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
|
||||||
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
|
|
||||||
} for t in toks],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/projects/{project_id}/portal-link/{token_id}/revoke")
|
|
||||||
async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)):
|
|
||||||
"""Revoke one shareable link (scoped to this project's client). Kills the link
|
|
||||||
and any live session minted from it on the next request."""
|
|
||||||
from datetime import datetime as _dt
|
|
||||||
from backend.models import Project, ClientAccessToken
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
if not project or not project.client_id:
|
|
||||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
|
||||||
tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first()
|
|
||||||
if not tok:
|
|
||||||
return JSONResponse(status_code=404, content={"detail": "Link not found"})
|
|
||||||
if not tok.revoked_at:
|
|
||||||
tok.revoked_at = _dt.utcnow()
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
||||||
async def nrl_detail_page(
|
async def nrl_detail_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Database migration: Client Portal (M1).
|
|
||||||
|
|
||||||
Adds the authoritative client link to projects:
|
|
||||||
- projects.client_id (TEXT, nullable) -> clients.id
|
|
||||||
|
|
||||||
The `clients` and `client_access_tokens` tables are created automatically by
|
|
||||||
SQLAlchemy `create_all` at app startup (they're brand-new tables), so this
|
|
||||||
migration only handles the column that create_all won't add to an existing
|
|
||||||
`projects` table.
|
|
||||||
|
|
||||||
Run once per database:
|
|
||||||
docker exec terra-view-terra-view-1 python3 backend/migrate_add_client_portal.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
possible_paths = [
|
|
||||||
Path("data/seismo_fleet.db"),
|
|
||||||
Path("data/sfm.db"),
|
|
||||||
Path("data/seismo.db"),
|
|
||||||
]
|
|
||||||
db_path = next((p for p in possible_paths if p.exists()), None)
|
|
||||||
if db_path is None:
|
|
||||||
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
|
||||||
print("A fresh DB created via models.py will include projects.client_id automatically.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Using database: {db_path}")
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("PRAGMA table_info(projects)")
|
|
||||||
existing = {row[1] for row in cursor.fetchall()}
|
|
||||||
|
|
||||||
if "client_id" not in existing:
|
|
||||||
try:
|
|
||||||
cursor.execute("ALTER TABLE projects ADD COLUMN client_id TEXT")
|
|
||||||
print("✓ Added column: projects.client_id (TEXT)")
|
|
||||||
except sqlite3.OperationalError as e:
|
|
||||||
print(f"✗ Failed to add projects.client_id: {e}")
|
|
||||||
else:
|
|
||||||
print("○ Column already exists: projects.client_id")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print("\n✓ Client-portal migration complete.")
|
|
||||||
print(" Note: `clients` + `client_access_tokens` tables auto-create on app startup.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate()
|
|
||||||
@@ -192,7 +192,6 @@ class Project(Base):
|
|||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
|
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||||
start_date = Column(Date, nullable=True)
|
start_date = Column(Date, nullable=True)
|
||||||
@@ -705,37 +704,3 @@ class PendingDeployment(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CLIENT PORTAL — read-only, scoped client access (see docs/CLIENT_PORTAL.md)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class Client(Base):
|
|
||||||
"""A portal client (customer org). Owns one or more Projects via
|
|
||||||
Project.client_id; their portal surfaces only those projects' locations.
|
|
||||||
Read-only — clients never control devices."""
|
|
||||||
__tablename__ = "clients"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
|
||||||
name = Column(String, nullable=False) # display name, e.g. "PJ Dick"
|
|
||||||
slug = Column(String, nullable=False, unique=True, index=True) # URL-safe handle
|
|
||||||
contact_email = Column(String, nullable=True) # for M4 magic-link
|
|
||||||
active = Column(Boolean, default=True) # False = portal access off
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class ClientAccessToken(Base):
|
|
||||||
"""Interim 'magic URL' gate (M1-M3). The raw secret lives in the link and is
|
|
||||||
shown once on creation; only its sha256 is stored here. Revoke by setting
|
|
||||||
revoked_at. In M4 this is replaced behind get_current_client() without
|
|
||||||
touching routes/templates."""
|
|
||||||
__tablename__ = "client_access_tokens"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
|
||||||
client_id = Column(String, nullable=False, index=True) # FK -> clients.id
|
|
||||||
token_hash = Column(String, nullable=False, index=True) # sha256 hex of the secret
|
|
||||||
label = Column(String, nullable=True) # e.g. "Dave's link"
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
last_used_at = Column(DateTime, nullable=True)
|
|
||||||
revoked_at = Column(DateTime, nullable=True) # set = link no longer works
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Client-portal admin CLI (M1). Operator tooling — run inside the terra-view
|
|
||||||
container against the live DB. The raw magic-link token is shown ONCE on mint;
|
|
||||||
only its hash is stored.
|
|
||||||
|
|
||||||
# create a client
|
|
||||||
python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com]
|
|
||||||
|
|
||||||
# attach a project to a client (sets Project.client_id) — by id, number, or name
|
|
||||||
python3 backend/portal_admin.py link-project --slug myler --project-id <PID>
|
|
||||||
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
|
|
||||||
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
|
|
||||||
|
|
||||||
# mint a magic access link (FULL URL PRINTED ONCE — copy it now)
|
|
||||||
python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"]
|
|
||||||
|
|
||||||
# list clients, their projects, and active links
|
|
||||||
python3 backend/portal_admin.py list
|
|
||||||
|
|
||||||
# revoke a link (stops the link AND any live session it minted)
|
|
||||||
python3 backend/portal_admin.py revoke --token-id <TID>
|
|
||||||
|
|
||||||
The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
import secrets
|
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0],
|
|
||||||
# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`.
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
from backend.database import SessionLocal
|
|
||||||
from backend.models import Client, ClientAccessToken, Project
|
|
||||||
from backend.portal_auth import hash_token
|
|
||||||
|
|
||||||
PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_client(db, slug):
|
|
||||||
c = db.query(Client).filter_by(slug=slug).first()
|
|
||||||
if not c:
|
|
||||||
sys.exit(f"No client with slug '{slug}'. Create it first.")
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def create_client(args):
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if db.query(Client).filter_by(slug=args.slug).first():
|
|
||||||
sys.exit(f"A client with slug '{args.slug}' already exists.")
|
|
||||||
c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug,
|
|
||||||
contact_email=args.email, active=True)
|
|
||||||
db.add(c)
|
|
||||||
db.commit()
|
|
||||||
print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})")
|
|
||||||
print(" Next: link-project, then mint-link.")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def link_project(args):
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
c = _get_client(db, args.slug)
|
|
||||||
q = db.query(Project)
|
|
||||||
if args.project_id:
|
|
||||||
p = q.filter_by(id=args.project_id).first()
|
|
||||||
elif args.project_number:
|
|
||||||
p = q.filter_by(project_number=args.project_number).first()
|
|
||||||
elif args.project_name:
|
|
||||||
p = q.filter_by(name=args.project_name).first()
|
|
||||||
else:
|
|
||||||
sys.exit("Specify --project-id, --project-number, or --project-name.")
|
|
||||||
if not p:
|
|
||||||
sys.exit("Project not found.")
|
|
||||||
p.client_id = c.id
|
|
||||||
db.commit()
|
|
||||||
print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def mint_link(args):
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
c = _get_client(db, args.slug)
|
|
||||||
raw = secrets.token_urlsafe(32)
|
|
||||||
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id,
|
|
||||||
token_hash=hash_token(raw), label=args.label)
|
|
||||||
db.add(tok)
|
|
||||||
db.commit()
|
|
||||||
print(f"✓ Minted access link for '{c.name}'"
|
|
||||||
f"{f' ({args.label})' if args.label else ''} — token id {tok.id}")
|
|
||||||
print("\n COPY THIS NOW (shown only once):\n")
|
|
||||||
print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def revoke(args):
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first()
|
|
||||||
if not tok:
|
|
||||||
sys.exit("No token with that id.")
|
|
||||||
if tok.revoked_at:
|
|
||||||
print("○ Already revoked.")
|
|
||||||
return
|
|
||||||
tok.revoked_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def list_all(args):
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
clients = db.query(Client).order_by(Client.name).all()
|
|
||||||
if not clients:
|
|
||||||
print("No clients yet.")
|
|
||||||
return
|
|
||||||
for c in clients:
|
|
||||||
state = "" if c.active else " [INACTIVE]"
|
|
||||||
print(f"\n● {c.name} (slug={c.slug}){state}")
|
|
||||||
projs = db.query(Project).filter_by(client_id=c.id).all()
|
|
||||||
print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)"))
|
|
||||||
toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all()
|
|
||||||
if not toks:
|
|
||||||
print(" links: (none — run mint-link)")
|
|
||||||
for t in toks:
|
|
||||||
status = "revoked" if t.revoked_at else "active"
|
|
||||||
last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used"
|
|
||||||
print(f" link {t.id} [{status}] {t.label or ''} (last: {last})")
|
|
||||||
print()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Client-portal admin (M1)")
|
|
||||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
||||||
|
|
||||||
p = sub.add_parser("create-client"); p.add_argument("--name", required=True)
|
|
||||||
p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client)
|
|
||||||
|
|
||||||
p = sub.add_parser("link-project"); p.add_argument("--slug", required=True)
|
|
||||||
p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name")
|
|
||||||
p.set_defaults(fn=link_project)
|
|
||||||
|
|
||||||
p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True)
|
|
||||||
p.add_argument("--label"); p.set_defaults(fn=mint_link)
|
|
||||||
|
|
||||||
p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke)
|
|
||||||
|
|
||||||
p = sub.add_parser("list"); p.set_defaults(fn=list_all)
|
|
||||||
|
|
||||||
args = ap.parse_args()
|
|
||||||
args.fn(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""
|
|
||||||
Client-portal auth — the swappable gate (see docs/CLIENT_PORTAL.md).
|
|
||||||
|
|
||||||
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
|
|
||||||
mints a signed session cookie. Every portal route depends on get_current_client();
|
|
||||||
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
|
|
||||||
|
|
||||||
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
|
|
||||||
against the DB on every request, so revoking a link (revoked_at) kills its live
|
|
||||||
sessions on the next request — not just future clicks.
|
|
||||||
|
|
||||||
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import Request, Depends
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from backend.database import get_db
|
|
||||||
from backend.models import Client, ClientAccessToken
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Signing secret for portal session cookies. MUST be set to a real secret in prod
|
|
||||||
# (env). The insecure default only exists so dev/test boots without config.
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
|
|
||||||
if SECRET_KEY == "dev-insecure-change-me":
|
|
||||||
logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")
|
|
||||||
|
|
||||||
COOKIE_NAME = "portal_session"
|
|
||||||
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
|
||||||
|
|
||||||
# Plain, no-token portal links (/portal/open/{project_id}). These are an
|
|
||||||
# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's
|
|
||||||
# open link grants the *whole* client's scope), so they default OFF and must be
|
|
||||||
# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env.
|
|
||||||
PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes")
|
|
||||||
if PORTAL_OPEN_LINKS:
|
|
||||||
logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
|
|
||||||
"Keep this OFF in any internet-facing / production deployment.")
|
|
||||||
|
|
||||||
|
|
||||||
class PortalAuthError(Exception):
|
|
||||||
"""Raised by get_current_client when there's no valid portal session.
|
|
||||||
Handled centrally in main.py: HTML routes get the access-required page,
|
|
||||||
/portal/api/* routes get a 401 JSON."""
|
|
||||||
|
|
||||||
|
|
||||||
# -- token + cookie primitives ----------------------------------------------
|
|
||||||
|
|
||||||
def hash_token(raw: str) -> str:
|
|
||||||
"""sha256 hex of a raw access-token secret (what we store + look up by)."""
|
|
||||||
return hashlib.sha256(raw.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(body: str) -> str:
|
|
||||||
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def make_session_cookie(token_id: str) -> str:
|
|
||||||
body = base64.urlsafe_b64encode(
|
|
||||||
json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
|
|
||||||
).decode()
|
|
||||||
return f"{body}.{_sign(body)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _read_session_cookie(value: str):
|
|
||||||
"""Return the token id from a signed cookie, or None if missing/tampered."""
|
|
||||||
try:
|
|
||||||
body, sig = value.rsplit(".", 1)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
return None
|
|
||||||
if not hmac.compare_digest(sig, _sign(body)):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = json.loads(base64.urlsafe_b64decode(body.encode()))
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
# Server-side expiry: a leaked cookie isn't valid forever (max_age is only a
|
|
||||||
# browser hint). iat is set by make_session_cookie.
|
|
||||||
iat = data.get("iat")
|
|
||||||
if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE:
|
|
||||||
return None
|
|
||||||
return data.get("tid")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# -- the dependency every portal route uses ---------------------------------
|
|
||||||
|
|
||||||
def client_from_cookie(cookie_value, db: Session):
|
|
||||||
"""Resolve a Client from a raw session-cookie value, or None. Re-validates the
|
|
||||||
access token against the DB each call, so a revoked link / disabled client
|
|
||||||
drops immediately. Shared by the HTTP dependency and the WebSocket handler
|
|
||||||
(which can't use Request-based Depends)."""
|
|
||||||
token_id = _read_session_cookie(cookie_value) if cookie_value else None
|
|
||||||
if not token_id:
|
|
||||||
return None
|
|
||||||
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
|
|
||||||
if not tok:
|
|
||||||
return None
|
|
||||||
return db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
|
|
||||||
"""Resolve the authenticated client, or raise PortalAuthError."""
|
|
||||||
client = client_from_cookie(request.cookies.get(COOKIE_NAME), db)
|
|
||||||
if client is None:
|
|
||||||
raise PortalAuthError()
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_token(raw_token: str, db: Session):
|
|
||||||
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on
|
|
||||||
success, or (None, None). Also stamps last_used_at."""
|
|
||||||
tok = db.query(ClientAccessToken).filter_by(
|
|
||||||
token_hash=hash_token(raw_token), revoked_at=None
|
|
||||||
).first()
|
|
||||||
if not tok:
|
|
||||||
return None, None
|
|
||||||
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
|
||||||
if not client:
|
|
||||||
return None, None
|
|
||||||
tok.last_used_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
return tok, client
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_project_client(project, db) -> Client:
|
|
||||||
"""Find or create the Client for a project. Reuses the project's linked client
|
|
||||||
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
|
|
||||||
sets project.client_id (only when unset, so it never clobbers a real link)."""
|
|
||||||
client = None
|
|
||||||
if project.client_id:
|
|
||||||
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
|
|
||||||
if client is None:
|
|
||||||
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
|
|
||||||
client = db.query(Client).filter_by(slug=slug).first()
|
|
||||||
if client is None:
|
|
||||||
client = Client(id=str(uuid.uuid4()),
|
|
||||||
name=(project.client_name or project.name or "Preview"),
|
|
||||||
slug=slug, active=True)
|
|
||||||
db.add(client)
|
|
||||||
db.flush()
|
|
||||||
if not project.client_id:
|
|
||||||
project.client_id = client.id
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
def mint_link_token(client, db, label=None) -> str:
|
|
||||||
"""Mint a fresh access token for a client and return the RAW secret (caller
|
|
||||||
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored."""
|
|
||||||
raw = secrets.token_urlsafe(32)
|
|
||||||
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
|
|
||||||
token_hash=hash_token(raw), label=label))
|
|
||||||
db.commit()
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def provision_preview_session(project, db) -> str:
|
|
||||||
"""Operator preview shortcut: ensure a Client + access token exist for a project
|
|
||||||
and return a token id to seal into a session cookie (no shared link). Reuses an
|
|
||||||
existing token so repeat previews don't accumulate clutter; the raw secret is
|
|
||||||
discarded (preview rides the cookie)."""
|
|
||||||
client = ensure_project_client(project, db)
|
|
||||||
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
|
|
||||||
if tok is None:
|
|
||||||
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
|
|
||||||
token_hash=hash_token(secrets.token_urlsafe(32)),
|
|
||||||
label="preview")
|
|
||||||
db.add(tok)
|
|
||||||
db.commit()
|
|
||||||
return tok.id
|
|
||||||
@@ -9,7 +9,6 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -141,7 +140,6 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
days = int(hours_ago / 24)
|
days = int(hours_ago / 24)
|
||||||
time_ago = f"{days}d ago"
|
time_ago = f"{days}d ago"
|
||||||
|
|
||||||
loc = get_active_location(db, emitter.id) if roster_unit else None
|
|
||||||
call_in = {
|
call_in = {
|
||||||
"unit_id": emitter.id,
|
"unit_id": emitter.id,
|
||||||
"last_seen": emitter.last_seen.isoformat(),
|
"last_seen": emitter.last_seen.isoformat(),
|
||||||
@@ -150,7 +148,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
||||||
"deployed": roster_unit.deployed if roster_unit else False,
|
"deployed": roster_unit.deployed if roster_unit else False,
|
||||||
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
|
||||||
}
|
}
|
||||||
call_ins.append(call_in)
|
call_ins.append(call_in)
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"""
|
|
||||||
Calibration Sync Router
|
|
||||||
|
|
||||||
Endpoints for triggering and inspecting the SFM-driven calibration sync.
|
|
||||||
The scheduled job runs daily; this router is what the "Sync now" button in
|
|
||||||
Settings calls, plus a status endpoint for diagnostics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
from backend.services.calibration_sync import (
|
|
||||||
sync_all_calibrations,
|
|
||||||
get_calibration_sync_scheduler,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/calibration", tags=["calibration"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync")
|
|
||||||
async def trigger_calibration_sync() -> Dict[str, Any]:
|
|
||||||
"""Run a full calibration sync now and return the summary."""
|
|
||||||
summary = await sync_all_calibrations()
|
|
||||||
get_calibration_sync_scheduler().last_run = summary
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sync/status")
|
|
||||||
def calibration_sync_status() -> Dict[str, Any]:
|
|
||||||
"""Return scheduler status and the most recent run's summary."""
|
|
||||||
return get_calibration_sync_scheduler().status()
|
|
||||||
@@ -750,17 +750,15 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
# Last seen from emitter
|
# Last seen from emitter
|
||||||
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||||
|
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
loc = get_active_location(db, u.id)
|
|
||||||
return {
|
return {
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"unit_type": u.unit_type,
|
"unit_type": u.unit_type,
|
||||||
"deployed": u.deployed,
|
"deployed": u.deployed,
|
||||||
"out_for_calibration": u.out_for_calibration or False,
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
"note": u.note or "",
|
"note": u.note or "",
|
||||||
"project_id": (loc or {}).get("project_id") or u.project_id or "",
|
"project_id": u.project_id or "",
|
||||||
"address": (loc or {}).get("address") or "",
|
"address": u.address or u.location or "",
|
||||||
"coordinates": (loc or {}).get("coordinates") or "",
|
"coordinates": u.coordinates or "",
|
||||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import logging
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -86,7 +85,8 @@ async def get_modem_units(
|
|||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.ip_address.ilike(search_term)) |
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
(RosterUnit.hardware_model.ilike(search_term)) |
|
(RosterUnit.hardware_model.ilike(search_term)) |
|
||||||
(RosterUnit.phone_number.ilike(search_term))
|
(RosterUnit.phone_number.ilike(search_term)) |
|
||||||
|
(RosterUnit.location.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
modems = query.order_by(
|
modems = query.order_by(
|
||||||
@@ -128,8 +128,6 @@ async def get_modem_units(
|
|||||||
if filter_status and status != filter_status:
|
if filter_status and status != filter_status:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Inherit location from the paired device's active assignment.
|
|
||||||
loc = get_active_location(db, modem.id) if paired else None
|
|
||||||
modem_list.append({
|
modem_list.append({
|
||||||
"id": modem.id,
|
"id": modem.id,
|
||||||
"ip_address": modem.ip_address,
|
"ip_address": modem.ip_address,
|
||||||
@@ -137,8 +135,8 @@ async def get_modem_units(
|
|||||||
"hardware_model": modem.hardware_model,
|
"hardware_model": modem.hardware_model,
|
||||||
"deployed": modem.deployed,
|
"deployed": modem.deployed,
|
||||||
"retired": modem.retired,
|
"retired": modem.retired,
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
"location": modem.location,
|
||||||
"project_id": (loc or {}).get("project_id") or modem.project_id,
|
"project_id": modem.project_id,
|
||||||
"paired_device": paired,
|
"paired_device": paired,
|
||||||
"status": status
|
"status": status
|
||||||
})
|
})
|
||||||
@@ -167,15 +165,14 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if device:
|
if device:
|
||||||
loc = get_active_location(db, device.id)
|
|
||||||
return {
|
return {
|
||||||
"paired": True,
|
"paired": True,
|
||||||
"device": {
|
"device": {
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": (loc or {}).get("project_id") or device.project_id,
|
"project_id": device.project_id,
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
"location": device.location or device.address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +314,8 @@ async def get_pairable_devices(
|
|||||||
query = query.filter(
|
query = query.filter(
|
||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.project_id.ilike(search_term)) |
|
(RosterUnit.project_id.ilike(search_term)) |
|
||||||
|
(RosterUnit.location.ilike(search_term)) |
|
||||||
|
(RosterUnit.address.ilike(search_term)) |
|
||||||
(RosterUnit.note.ilike(search_term))
|
(RosterUnit.note.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -339,13 +338,12 @@ async def get_pairable_devices(
|
|||||||
if hide_paired and is_paired_to_other:
|
if hide_paired and is_paired_to_other:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
loc = get_active_location(db, device.id)
|
|
||||||
device_list.append({
|
device_list.append({
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": (loc or {}).get("project_id") or device.project_id,
|
"project_id": device.project_id,
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
"location": device.location or device.address,
|
||||||
"note": device.note,
|
"note": device.note,
|
||||||
"paired_modem_id": device.deployed_with_modem_id,
|
"paired_modem_id": device.deployed_with_modem_id,
|
||||||
"is_paired_to_this": is_paired_to_this,
|
"is_paired_to_this": is_paired_to_this,
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
"""
|
|
||||||
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
|
||||||
|
|
||||||
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
|
|
||||||
session cookie, then sees their locations (overview) and per-location read-only
|
|
||||||
live data sourced from SLMM's cache. Every data route re-checks ownership.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from sqlalchemy import or_
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from backend.database import get_db, SessionLocal
|
|
||||||
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
|
|
||||||
from backend.templates_config import templates
|
|
||||||
from backend.portal_auth import (
|
|
||||||
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
|
|
||||||
provision_preview_session, PORTAL_OPEN_LINKS,
|
|
||||||
COOKIE_NAME, COOKIE_MAX_AGE,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(prefix="/portal", tags=["portal"])
|
|
||||||
|
|
||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
|
||||||
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
|
|
||||||
|
|
||||||
# Whitelist of fields the portal exposes to a client — sound metrics + run state
|
|
||||||
# only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed.
|
|
||||||
_PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time",
|
|
||||||
"lp", "leq", "lmax", "lpeak", "ln1", "ln2")
|
|
||||||
|
|
||||||
|
|
||||||
# -- scoping (every data route gates through these) --------------------------
|
|
||||||
|
|
||||||
def _client_project_ids(client: Client, db: Session) -> list:
|
|
||||||
return [r[0] for r in db.query(Project.id).filter(
|
|
||||||
Project.client_id == client.id, Project.status != "deleted").all()]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation:
|
|
||||||
"""Ownership gate: location must be a sound location in one of the client's
|
|
||||||
active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so
|
|
||||||
we never leak whether a location exists."""
|
|
||||||
loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first()
|
|
||||||
if (not loc or loc.location_type != "sound"
|
|
||||||
or loc.project_id not in _client_project_ids(client, db)):
|
|
||||||
raise HTTPException(status_code=404, detail="Location not found")
|
|
||||||
return loc
|
|
||||||
|
|
||||||
|
|
||||||
def active_unit_for_location(location_id: str, db: Session):
|
|
||||||
"""The SLM unit currently assigned to this location, or None."""
|
|
||||||
now = datetime.utcnow()
|
|
||||||
asg = (db.query(UnitAssignment)
|
|
||||||
.filter(UnitAssignment.location_id == location_id,
|
|
||||||
UnitAssignment.status == "active",
|
|
||||||
UnitAssignment.device_type == "slm",
|
|
||||||
or_(UnitAssignment.assigned_until.is_(None),
|
|
||||||
UnitAssignment.assigned_until > now))
|
|
||||||
.order_by(UnitAssignment.assigned_at.desc()).first())
|
|
||||||
return asg.unit_id if asg else None
|
|
||||||
|
|
||||||
|
|
||||||
def _client_locations(client: Client, db: Session) -> list:
|
|
||||||
"""The client's active sound locations (for the overview tiles + map)."""
|
|
||||||
pids = _client_project_ids(client, db)
|
|
||||||
if not pids:
|
|
||||||
return []
|
|
||||||
projs = {p.id: p.name for p in
|
|
||||||
db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()}
|
|
||||||
locs = (db.query(MonitoringLocation)
|
|
||||||
.filter(MonitoringLocation.project_id.in_(pids),
|
|
||||||
MonitoringLocation.location_type == "sound",
|
|
||||||
MonitoringLocation.removed_at.is_(None))
|
|
||||||
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all())
|
|
||||||
return [{
|
|
||||||
"id": loc.id, "name": loc.name,
|
|
||||||
"address": loc.address, "coordinates": loc.coordinates,
|
|
||||||
"project_name": projs.get(loc.project_id),
|
|
||||||
"has_device": active_unit_for_location(loc.id, db) is not None,
|
|
||||||
} for loc in locs]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/enter/{token}")
|
|
||||||
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
|
|
||||||
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
|
|
||||||
tok, client = resolve_token(token, db)
|
|
||||||
if not client:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"portal/access_required.html",
|
|
||||||
{"request": request, "reason": "invalid"},
|
|
||||||
status_code=403,
|
|
||||||
)
|
|
||||||
resp = RedirectResponse(url="/portal", status_code=303)
|
|
||||||
resp.set_cookie(
|
|
||||||
COOKIE_NAME, make_session_cookie(tok.id),
|
|
||||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
|
|
||||||
)
|
|
||||||
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/open/{project_id}")
|
|
||||||
def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)):
|
|
||||||
"""Dev-only plain shareable link: open a project's client portal with no token
|
|
||||||
(gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback —
|
|
||||||
sets the session cookie and lands on /portal. Lives under /portal so it works
|
|
||||||
through a reverse proxy that exposes only /portal/*."""
|
|
||||||
if not PORTAL_OPEN_LINKS:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"portal/access_required.html", {"request": request, "reason": "required"},
|
|
||||||
status_code=404)
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
if not project:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
|
||||||
status_code=404)
|
|
||||||
token_id = provision_preview_session(project, db)
|
|
||||||
resp = RedirectResponse(url="/portal", status_code=303)
|
|
||||||
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
|
||||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
|
||||||
def portal_logout():
|
|
||||||
resp = RedirectResponse(url="/portal/access", status_code=303)
|
|
||||||
resp.delete_cookie(COOKIE_NAME)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/access")
|
|
||||||
def portal_access(request: Request):
|
|
||||||
"""Landing for an unauthenticated visitor (no valid link)."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"portal/access_required.html", {"request": request, "reason": "required"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def portal_home(request: Request, client: Client = Depends(get_current_client),
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
"""Client overview — their active sound locations with live tiles + a map."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"portal/overview.html",
|
|
||||||
{"request": request, "client": client,
|
|
||||||
"locations": _client_locations(client, db)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/location/{location_id}")
|
|
||||||
def portal_location(location_id: str, request: Request,
|
|
||||||
client: Client = Depends(get_current_client),
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
"""Read-only live view for one of the client's locations (404 if not owned)."""
|
|
||||||
loc = resolve_client_location(client, location_id, db)
|
|
||||||
return templates.TemplateResponse("portal/location.html", {
|
|
||||||
"request": request, "client": client, "location": loc,
|
|
||||||
"has_device": active_unit_for_location(location_id, db) is not None,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# -- scoped data (cache reads only — never hits the device) ------------------
|
|
||||||
|
|
||||||
@router.get("/api/location/{location_id}/live")
|
|
||||||
async def portal_location_live(location_id: str,
|
|
||||||
client: Client = Depends(get_current_client),
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
"""Scrubbed cached live reading for a location the client owns."""
|
|
||||||
resolve_client_location(client, location_id, db)
|
|
||||||
unit_id = active_unit_for_location(location_id, db)
|
|
||||||
if not unit_id:
|
|
||||||
return {"status": "ok", "data": None, "reason": "no_device"}
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
||||||
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
|
||||||
except Exception:
|
|
||||||
return {"status": "ok", "data": None, "reason": "unreachable"}
|
|
||||||
if r.status_code != 200:
|
|
||||||
return {"status": "ok", "data": None, "reason": "no_data"}
|
|
||||||
full = (r.json() or {}).get("data", {}) or {}
|
|
||||||
return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/location/{location_id}/history")
|
|
||||||
async def portal_location_history(location_id: str, hours: float = 2.0,
|
|
||||||
client: Client = Depends(get_current_client),
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
"""Cached chart trail for a location the client owns. (Trail rows are already
|
|
||||||
just timestamp + lp/leq/lmax/ln1/ln2 — safe to pass through.)"""
|
|
||||||
resolve_client_location(client, location_id, db)
|
|
||||||
unit_id = active_unit_for_location(location_id, db)
|
|
||||||
if not unit_id:
|
|
||||||
return {"status": "ok", "readings": []}
|
|
||||||
hours = max(0.1, min(hours, 48.0))
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
||||||
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history",
|
|
||||||
params={"hours": hours})
|
|
||||||
except Exception:
|
|
||||||
return {"status": "ok", "readings": []}
|
|
||||||
if r.status_code != 200:
|
|
||||||
return {"status": "ok", "readings": []}
|
|
||||||
raw = (r.json() or {}).get("readings", [])
|
|
||||||
fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints
|
|
||||||
return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]}
|
|
||||||
|
|
||||||
|
|
||||||
# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
|
|
||||||
_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
|
|
||||||
"onset_value", "peak_value", "clear_at", "status")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/location/{location_id}/events")
|
|
||||||
async def portal_location_events(location_id: str, limit: int = 20,
|
|
||||||
client: Client = Depends(get_current_client),
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
"""Scrubbed breach history for a location the client owns (read-only)."""
|
|
||||||
resolve_client_location(client, location_id, db)
|
|
||||||
unit_id = active_unit_for_location(location_id, db)
|
|
||||||
if not unit_id:
|
|
||||||
return {"status": "ok", "events": []}
|
|
||||||
limit = max(1, min(limit, 100))
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
||||||
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
|
|
||||||
params={"limit": limit})
|
|
||||||
except Exception:
|
|
||||||
return {"status": "ok", "events": []}
|
|
||||||
if r.status_code != 200:
|
|
||||||
return {"status": "ok", "events": []}
|
|
||||||
raw = (r.json() or {}).get("events", [])
|
|
||||||
events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
|
|
||||||
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
|
|
||||||
|
|
||||||
|
|
||||||
# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/
|
|
||||||
# hysteresis internals).
|
|
||||||
_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s",
|
|
||||||
"schedule_start", "schedule_end", "schedule_days")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/location/{location_id}/thresholds")
|
|
||||||
async def portal_location_thresholds(location_id: str,
|
|
||||||
client: Client = Depends(get_current_client),
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
"""The active alert limits for a location the client owns (enabled rules only),
|
|
||||||
so the client can see what they're being alerted on. Read-only, scrubbed."""
|
|
||||||
resolve_client_location(client, location_id, db)
|
|
||||||
unit_id = active_unit_for_location(location_id, db)
|
|
||||||
if not unit_id:
|
|
||||||
return {"status": "ok", "rules": []}
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as hc:
|
|
||||||
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules")
|
|
||||||
except Exception:
|
|
||||||
return {"status": "ok", "rules": []}
|
|
||||||
if r.status_code != 200:
|
|
||||||
return {"status": "ok", "rules": []}
|
|
||||||
raw = (r.json() or {}).get("rules", [])
|
|
||||||
rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")]
|
|
||||||
return {"status": "ok", "rules": rules}
|
|
||||||
|
|
||||||
|
|
||||||
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
|
|
||||||
|
|
||||||
def _scrub_frame(raw: str):
|
|
||||||
"""Project a monitor frame down to the portal whitelist. Drops internal fields
|
|
||||||
(unit_id, raw_payload, lmin) before it reaches a client; passes control fields
|
|
||||||
(feed_status, heartbeat) + timestamp through. Returns None for a non-JSON frame
|
|
||||||
so the caller drops it rather than forwarding anything unscrubbed."""
|
|
||||||
try:
|
|
||||||
d = json.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d}
|
|
||||||
if "timestamp" in d:
|
|
||||||
out["timestamp"] = d["timestamp"]
|
|
||||||
for ctrl in ("feed_status", "heartbeat"):
|
|
||||||
if ctrl in d:
|
|
||||||
out[ctrl] = d[ctrl]
|
|
||||||
return json.dumps(out)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/api/location/{location_id}/stream")
|
|
||||||
async def portal_location_stream(websocket: WebSocket, location_id: str):
|
|
||||||
"""Live ~1Hz feed for a location the client owns. Auths via the session cookie,
|
|
||||||
enforces ownership, then bridges the unit's shared SLMM /monitor fan-out feed
|
|
||||||
to the browser (scrubbed). A viewer is just one more subscriber to the one
|
|
||||||
device feed — no extra device connection."""
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
# Auth + ownership on a short-lived session, then release it for the long bridge.
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
client = client_from_cookie(websocket.cookies.get(COOKIE_NAME), db)
|
|
||||||
if client is None:
|
|
||||||
await websocket.close(code=1008) # policy violation (not authenticated)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
resolve_client_location(client, location_id, db)
|
|
||||||
except HTTPException:
|
|
||||||
await websocket.close(code=1008)
|
|
||||||
return
|
|
||||||
unit_id = active_unit_for_location(location_id, db)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if not unit_id:
|
|
||||||
try:
|
|
||||||
await websocket.send_json({"feed_status": "no_device"})
|
|
||||||
finally:
|
|
||||||
await websocket.close(code=1000)
|
|
||||||
return
|
|
||||||
|
|
||||||
target = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
|
|
||||||
backend_ws = None
|
|
||||||
try:
|
|
||||||
backend_ws = await websockets.connect(target)
|
|
||||||
|
|
||||||
async def forward_to_client():
|
|
||||||
async for message in backend_ws:
|
|
||||||
frame = _scrub_frame(message)
|
|
||||||
if frame is not None:
|
|
||||||
await websocket.send_text(frame)
|
|
||||||
|
|
||||||
async def watch_client():
|
|
||||||
while True:
|
|
||||||
await websocket.receive_text()
|
|
||||||
|
|
||||||
tasks = [asyncio.ensure_future(forward_to_client()),
|
|
||||||
asyncio.ensure_future(watch_client())]
|
|
||||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
||||||
for t in pending:
|
|
||||||
t.cancel()
|
|
||||||
for t in tasks:
|
|
||||||
try:
|
|
||||||
await t
|
|
||||||
except (asyncio.CancelledError, Exception):
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[PORTAL] stream {location_id}: {e}")
|
|
||||||
finally:
|
|
||||||
if backend_ws:
|
|
||||||
try:
|
|
||||||
await backend_ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@@ -1483,13 +1483,11 @@ async def get_available_units(
|
|||||||
).distinct().all()
|
).distinct().all()
|
||||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||||
|
|
||||||
# These units have no active assignment by definition, so there's no
|
|
||||||
# current location to show — leave the field empty.
|
|
||||||
available_units = [
|
available_units = [
|
||||||
{
|
{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"location": "",
|
"location": unit.address or unit.location,
|
||||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||||
"deployed": bool(unit.deployed),
|
"deployed": bool(unit.deployed),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from backend.database import get_db
|
|||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||||
import uuid
|
import uuid
|
||||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -183,6 +182,9 @@ async def add_roster_unit(
|
|||||||
out_for_calibration: str = Form(None),
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -247,6 +249,9 @@ async def add_roster_unit(
|
|||||||
out_for_calibration=out_for_calibration_bool,
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
location=location,
|
||||||
|
address=address,
|
||||||
|
coordinates=coordinates,
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated=last_cal_date,
|
last_calibrated=last_cal_date,
|
||||||
@@ -268,15 +273,19 @@ async def add_roster_unit(
|
|||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty.
|
# Auto-fill data from modem if pairing and fields are empty
|
||||||
# Location/address/coordinates now come from MonitoringLocation via the
|
|
||||||
# active UnitAssignment, so there's nothing to copy from the modem row.
|
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
if modem:
|
||||||
|
if not unit.location and modem.location:
|
||||||
|
unit.location = modem.location
|
||||||
|
if not unit.address and modem.address:
|
||||||
|
unit.address = modem.address
|
||||||
|
if not unit.coordinates and modem.coordinates:
|
||||||
|
unit.coordinates = modem.coordinates
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -484,8 +493,6 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
active_loc = get_active_location(db, unit_id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type or "seismograph",
|
"device_type": unit.device_type or "seismograph",
|
||||||
@@ -497,11 +504,9 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"active_location": active_loc,
|
"location": unit.location or "",
|
||||||
# Convenience fields so the unit-detail page can read the same shape
|
"address": unit.address or "",
|
||||||
# whether or not there's an active assignment.
|
"coordinates": unit.coordinates or "",
|
||||||
"address": (active_loc or {}).get("address") or "",
|
|
||||||
"coordinates": (active_loc or {}).get("coordinates") or "",
|
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
||||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
@@ -533,6 +538,9 @@ async def edit_roster_unit(
|
|||||||
allocated_to_project_id: str = Form(None),
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -557,6 +565,8 @@ async def edit_roster_unit(
|
|||||||
cascade_deployed: str = Form(None),
|
cascade_deployed: str = Form(None),
|
||||||
cascade_retired: str = Form(None),
|
cascade_retired: str = Form(None),
|
||||||
cascade_project: str = Form(None),
|
cascade_project: str = Form(None),
|
||||||
|
cascade_location: str = Form(None),
|
||||||
|
cascade_coordinates: str = Form(None),
|
||||||
cascade_note: str = Form(None),
|
cascade_note: str = Form(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -610,6 +620,9 @@ async def edit_roster_unit(
|
|||||||
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
|
unit.location = location
|
||||||
|
unit.address = address
|
||||||
|
unit.coordinates = coordinates
|
||||||
unit.last_updated = datetime.utcnow()
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -617,15 +630,20 @@ async def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_cal_date
|
unit.next_calibration_due = next_cal_date
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty.
|
# Auto-fill data from modem if pairing and fields are empty
|
||||||
# Location/address/coordinates live on MonitoringLocation now, nothing
|
|
||||||
# to copy across roster rows.
|
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
if modem:
|
||||||
|
# Only fill if the device field is empty
|
||||||
|
if not unit.location and modem.location:
|
||||||
|
unit.location = modem.location
|
||||||
|
if not unit.address and modem.address:
|
||||||
|
unit.address = modem.address
|
||||||
|
if not unit.coordinates and modem.coordinates:
|
||||||
|
unit.coordinates = modem.coordinates
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -751,6 +769,26 @@ async def edit_roster_unit(
|
|||||||
record_history(db, paired_unit.id, "project_change", "project_id",
|
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||||
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade address/location
|
||||||
|
if cascade_location in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_address = paired_unit.address
|
||||||
|
old_paired_location = paired_unit.location
|
||||||
|
paired_unit.address = address
|
||||||
|
paired_unit.location = location
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_address != address:
|
||||||
|
record_history(db, paired_unit.id, "address_change", "address",
|
||||||
|
old_paired_address or "", address or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade coordinates
|
||||||
|
if cascade_coordinates in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_coords = paired_unit.coordinates
|
||||||
|
paired_unit.coordinates = coordinates
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_coords != coordinates:
|
||||||
|
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
|
||||||
|
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
# Cascade note
|
# Cascade note
|
||||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||||
old_paired_note = paired_unit.note
|
old_paired_note = paired_unit.note
|
||||||
@@ -973,8 +1011,9 @@ async def import_csv(
|
|||||||
- retired: Boolean
|
- retired: Boolean
|
||||||
- note: Notes about the unit
|
- note: Notes about the unit
|
||||||
- project_id: Project identifier
|
- project_id: Project identifier
|
||||||
(Location / address / coordinates are not roster fields anymore — they
|
- location: Location description
|
||||||
live on the MonitoringLocation a unit is assigned to.)
|
- address: Street address
|
||||||
|
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
||||||
|
|
||||||
Seismograph-specific:
|
Seismograph-specific:
|
||||||
- last_calibrated: Date (YYYY-MM-DD)
|
- last_calibrated: Date (YYYY-MM-DD)
|
||||||
@@ -1087,6 +1126,9 @@ async def import_csv(
|
|||||||
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
||||||
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
||||||
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
||||||
|
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
|
||||||
|
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
|
||||||
|
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
|
||||||
existing_unit.last_updated = datetime.utcnow()
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -1152,6 +1194,9 @@ async def import_csv(
|
|||||||
retired=_parse_bool(row.get('retired', '')),
|
retired=_parse_bool(row.get('retired', '')),
|
||||||
note=_get_csv_value(row, 'note', ''),
|
note=_get_csv_value(row, 'note', ''),
|
||||||
project_id=_get_csv_value(row, 'project_id'),
|
project_id=_get_csv_value(row, 'project_id'),
|
||||||
|
location=_get_csv_value(row, 'location'),
|
||||||
|
address=_get_csv_value(row, 'address'),
|
||||||
|
coordinates=_get_csv_value(row, 'coordinates'),
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
||||||
last_calibrated=last_cal,
|
last_calibrated=last_cal,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from pathlib import Path
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
from backend.services.unit_location import bulk_active_locations
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -22,14 +21,11 @@ def export_roster_csv(db: Session = Depends(get_db)):
|
|||||||
"""Export all roster units to CSV"""
|
"""Export all roster units to CSV"""
|
||||||
units = db.query(RosterUnit).all()
|
units = db.query(RosterUnit).all()
|
||||||
|
|
||||||
# Create CSV in memory. Location lives on MonitoringLocation now, so
|
# Create CSV in memory
|
||||||
# we don't export legacy address/coordinates/location columns here —
|
|
||||||
# round-trip CSV editing would otherwise look like it edits unit
|
|
||||||
# location, when it can't.
|
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
||||||
'note', 'project_id',
|
'note', 'project_id', 'location', 'address', 'coordinates',
|
||||||
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||||
'ip_address', 'phone_number', 'hardware_model'
|
'ip_address', 'phone_number', 'hardware_model'
|
||||||
]
|
]
|
||||||
@@ -46,6 +42,9 @@ def export_roster_csv(db: Session = Depends(get_db)):
|
|||||||
'retired': 'true' if unit.retired else 'false',
|
'retired': 'true' if unit.retired else 'false',
|
||||||
'note': unit.note or '',
|
'note': unit.note or '',
|
||||||
'project_id': unit.project_id or '',
|
'project_id': unit.project_id or '',
|
||||||
|
'location': unit.location or '',
|
||||||
|
'address': unit.address or '',
|
||||||
|
'coordinates': unit.coordinates or '',
|
||||||
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
||||||
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
|
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
|
||||||
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
||||||
@@ -83,7 +82,6 @@ def get_table_stats(db: Session = Depends(get_db)):
|
|||||||
def get_all_roster_units(db: Session = Depends(get_db)):
|
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||||
"""Get all roster units for management table"""
|
"""Get all roster units for management table"""
|
||||||
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||||
active_locs = bulk_active_locations(db, units)
|
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
@@ -92,10 +90,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"address": (active_locs.get(unit.id) or {}).get("address") or "",
|
"location": unit.location or "",
|
||||||
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
|
"address": unit.address or "",
|
||||||
"location_name": (active_locs.get(unit.id) or {}).get("name") or "",
|
"coordinates": unit.coordinates or "",
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
|
|||||||
@@ -91,43 +91,29 @@ async def get_slm_units(
|
|||||||
|
|
||||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||||
for unit in units:
|
for unit in units:
|
||||||
# Legacy default from the roster field; refined from SLMM's cached status below.
|
|
||||||
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
|
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
|
||||||
unit.measurement_state = None
|
|
||||||
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
|
|
||||||
|
|
||||||
if include_measurement:
|
if include_measurement:
|
||||||
# SLMM's /roster carries each unit's CACHED status (last_seen,
|
async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
|
||||||
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device
|
try:
|
||||||
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects
|
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
|
||||||
# real monitoring without sending Measure? to the device (which the old
|
if response.status_code == 200:
|
||||||
# /measurement-state did) and competing with DOD polling. One call covers all.
|
return response.json().get("measurement_state")
|
||||||
slmm_status = {}
|
except Exception:
|
||||||
try:
|
return None
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
return None
|
||||||
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster")
|
|
||||||
if r.status_code == 200:
|
|
||||||
for dev in (r.json().get("devices") or []):
|
|
||||||
slmm_status[dev.get("unit_id")] = dev.get("status") or {}
|
|
||||||
except Exception:
|
|
||||||
slmm_status = {}
|
|
||||||
|
|
||||||
# "Recent" = the monitor has a fresh successful read. last_seen only advances
|
deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
|
||||||
# on a successful poll, so staleness == the device isn't being reached.
|
if deployed_units:
|
||||||
recent_cutoff = datetime.utcnow() - timedelta(minutes=5)
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
for unit in units:
|
tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
|
||||||
st = slmm_status.get(unit.id)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
if not st:
|
|
||||||
continue
|
for unit, state in zip(deployed_units, results):
|
||||||
unit.measurement_state = st.get("measurement_state")
|
if isinstance(state, Exception):
|
||||||
last_seen = st.get("last_seen")
|
unit.measurement_state = None
|
||||||
if last_seen:
|
else:
|
||||||
try:
|
unit.measurement_state = state
|
||||||
ls = datetime.fromisoformat(last_seen.replace("Z", ""))
|
|
||||||
unit.is_recent = ls > recent_cutoff
|
|
||||||
unit.cache_last_seen = ls # the real freshness the monitor updates
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/slm_device_list.html", {
|
return templates.TemplateResponse("partials/slm_device_list.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -171,18 +157,25 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
is_measuring = False
|
is_measuring = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we
|
# Get measurement state
|
||||||
# no longer fire Measure? + a fresh DOD read at the device on every command-
|
state_response = await client.get(
|
||||||
# center load (which competed with DOD polling for the single connection).
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
)
|
||||||
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
if state_response.status_code == 200:
|
||||||
if r.status_code == 200:
|
state_data = state_response.json()
|
||||||
current_status = r.json().get("data", {})
|
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||||
measurement_state = current_status.get("measurement_state")
|
is_measuring = state_data.get("is_measuring", False)
|
||||||
is_measuring = measurement_state in ("Start", "Measure")
|
|
||||||
|
# Get live status (measurement_start_time is already stored in SLMM database)
|
||||||
|
status_response = await client.get(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||||
|
)
|
||||||
|
if status_response.status_code == 200:
|
||||||
|
status_data = status_response.json()
|
||||||
|
current_status = status_data.get("data", {})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get cached status for {unit_id}: {e}")
|
logger.error(f"Failed to get status for {unit_id}: {e}")
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/slm_live_view.html", {
|
return templates.TemplateResponse("partials/slm_live_view.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import os
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -59,14 +58,13 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||||
|
|
||||||
loc = get_active_location(db, unit_id)
|
|
||||||
return {
|
return {
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"device_type": "slm",
|
"device_type": "slm",
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"model": unit.slm_model or "NL-43",
|
"model": unit.slm_model or "NL-43",
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
"location": unit.address or unit.location,
|
||||||
"coordinates": (loc or {}).get("coordinates") or "",
|
"coordinates": unit.coordinates,
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"status": status_data,
|
"status": status_data,
|
||||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
|||||||
@@ -231,76 +231,6 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
|
|||||||
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/{unit_id}/monitor")
|
|
||||||
async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str):
|
|
||||||
"""
|
|
||||||
Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed).
|
|
||||||
|
|
||||||
This is the shared ~1Hz DOD feed: many clients subscribe to one device feed
|
|
||||||
(no single-connection contention) and it carries L1/L10 (which the DRD
|
|
||||||
/stream cannot). Preferred over /stream for the live view.
|
|
||||||
"""
|
|
||||||
await websocket.accept()
|
|
||||||
logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)")
|
|
||||||
|
|
||||||
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
|
|
||||||
backend_ws = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
backend_ws = await websockets.connect(target_ws_url)
|
|
||||||
logger.info(f"Connected to SLMM monitor feed for {unit_id}")
|
|
||||||
|
|
||||||
async def forward_to_client():
|
|
||||||
"""Backend monitor frames -> browser."""
|
|
||||||
async for message in backend_ws:
|
|
||||||
await websocket.send_text(message)
|
|
||||||
|
|
||||||
async def watch_client():
|
|
||||||
"""Drain client frames; raises WebSocketDisconnect on close so we can
|
|
||||||
tear the pair down (the monitor feed is server->client only)."""
|
|
||||||
while True:
|
|
||||||
await websocket.receive_text()
|
|
||||||
|
|
||||||
# When EITHER side ends (browser disconnects or backend closes), cancel the
|
|
||||||
# other immediately — avoids sending into a closed socket (the
|
|
||||||
# "Unexpected ASGI message after close" race that asyncio.gather leaves open).
|
|
||||||
tasks = [asyncio.ensure_future(forward_to_client()),
|
|
||||||
asyncio.ensure_future(watch_client())]
|
|
||||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
||||||
for t in pending:
|
|
||||||
t.cancel()
|
|
||||||
# Await ALL tasks (the done one AND the cancelled one) and swallow both
|
|
||||||
# the expected WebSocketDisconnect and CancelledError. CancelledError is a
|
|
||||||
# BaseException, so a bare `except Exception` misses it — that's what leaked
|
|
||||||
# the traceback on stop; and awaiting only `pending` left the done task's
|
|
||||||
# exception unretrieved.
|
|
||||||
for t in tasks:
|
|
||||||
try:
|
|
||||||
await t
|
|
||||||
except (asyncio.CancelledError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
except websockets.exceptions.WebSocketException as e:
|
|
||||||
logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}")
|
|
||||||
try:
|
|
||||||
await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}")
|
|
||||||
finally:
|
|
||||||
if backend_ws:
|
|
||||||
try:
|
|
||||||
await backend_ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
await websocket.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info(f"WebSocket monitor proxy closed for {unit_id}")
|
|
||||||
|
|
||||||
|
|
||||||
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
||||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy_to_slmm(path: str, request: Request):
|
async def proxy_to_slmm(path: str, request: Request):
|
||||||
|
|||||||
+16
-12
@@ -5,7 +5,6 @@ from typing import Dict, Any, Optional
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
@@ -14,8 +13,7 @@ router = APIRouter(prefix="/api", tags=["units"])
|
|||||||
@router.get("/unit/{unit_id}")
|
@router.get("/unit/{unit_id}")
|
||||||
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Returns detailed data for a single unit, including its active deployment
|
Returns detailed data for a single unit.
|
||||||
location (or None if benched / unassigned).
|
|
||||||
"""
|
"""
|
||||||
snapshot = emit_status_snapshot()
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
@@ -23,7 +21,17 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
unit_data = snapshot["units"][unit_id]
|
unit_data = snapshot["units"][unit_id]
|
||||||
active_loc = get_active_location(db, unit_id)
|
|
||||||
|
# Mock coordinates for now (will be replaced with real data)
|
||||||
|
mock_coords = {
|
||||||
|
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
||||||
|
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
||||||
|
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
||||||
|
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
||||||
|
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
||||||
|
}
|
||||||
|
|
||||||
|
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
@@ -33,7 +41,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"last_file": unit_data.get("fname", ""),
|
"last_file": unit_data.get("fname", ""),
|
||||||
"deployed": unit_data["deployed"],
|
"deployed": unit_data["deployed"],
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"active_location": active_loc,
|
"coordinates": coords
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -41,16 +49,12 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Get unit data directly from the roster (for settings/configuration).
|
Get unit data directly from the roster (for settings/configuration).
|
||||||
Address/coordinates come from the active MonitoringLocation, not the
|
|
||||||
roster row.
|
|
||||||
"""
|
"""
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
active_loc = get_active_location(db, unit_id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
@@ -58,9 +62,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"active_location": active_loc,
|
"location": unit.location,
|
||||||
"address": (active_loc or {}).get("address") or "",
|
"address": unit.address,
|
||||||
"coordinates": (active_loc or {}).get("coordinates") or "",
|
"coordinates": unit.coordinates,
|
||||||
"slm_host": unit.slm_host,
|
"slm_host": unit.slm_host,
|
||||||
"slm_tcp_port": unit.slm_tcp_port,
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
"slm_ftp_port": unit.slm_ftp_port,
|
"slm_ftp_port": unit.slm_ftp_port,
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
"""
|
|
||||||
Calibration Sync Service
|
|
||||||
|
|
||||||
Pulls device-reported calibration dates from SFM event sidecars and updates
|
|
||||||
RosterUnit.last_calibrated when the device has a newer record than what
|
|
||||||
Terra-View has stored.
|
|
||||||
|
|
||||||
Conflict rule: events-as-truth, but don't go backwards.
|
|
||||||
- If the newest event's calibration_date == unit.last_calibrated → no-op.
|
|
||||||
- If the last UnitHistory change for last_calibrated is newer than the
|
|
||||||
newest event's timestamp → skip (a manual edit was made after this
|
|
||||||
event landed; manual wins until a fresher event arrives).
|
|
||||||
- Otherwise → write the event's calibration_date, recompute
|
|
||||||
next_calibration_due, and log a UnitHistory row with source='sfm_event'.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
from typing import Optional, Dict, Any, List
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import schedule
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from backend.database import SessionLocal
|
|
||||||
from backend.models import RosterUnit, UnitHistory, UserPreferences
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_cal_interval(db: Session) -> int:
|
|
||||||
prefs = db.query(UserPreferences).first()
|
|
||||||
if prefs and prefs.calibration_interval_days:
|
|
||||||
return prefs.calibration_interval_days
|
|
||||||
return 365
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_event_ts(value: Any) -> Optional[datetime]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return value.replace(tzinfo=None) if value.tzinfo else value
|
|
||||||
try:
|
|
||||||
s = str(value).replace("Z", "")
|
|
||||||
if "+" in s:
|
|
||||||
s = s.split("+", 1)[0]
|
|
||||||
return datetime.fromisoformat(s)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
logger.warning(f"Could not parse event timestamp: {value!r}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_cal_date(value: Any) -> Optional[date]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
if isinstance(value, date) and not isinstance(value, datetime):
|
|
||||||
return value
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return value.date()
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(str(value)).date()
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
try:
|
|
||||||
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
logger.warning(f"Could not parse calibration_date: {value!r}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_latest_event(client: httpx.AsyncClient, serial: str) -> Optional[Dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{SFM_BASE_URL}/db/events",
|
|
||||||
params={"serial": serial, "limit": 1},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
events = data.get("events", [])
|
|
||||||
return events[0] if events else None
|
|
||||||
except (httpx.HTTPError, ValueError) as e:
|
|
||||||
logger.warning(f"Failed to fetch latest event for {serial}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_event_sidecar(client: httpx.AsyncClient, event_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
resp = await client.get(f"{SFM_BASE_URL}/db/events/{event_id}/sidecar")
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
except (httpx.HTTPError, ValueError) as e:
|
|
||||||
logger.warning(f"Failed to fetch sidecar for event {event_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_unit_calibration(
|
|
||||||
db: Session,
|
|
||||||
unit: RosterUnit,
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Sync calibration for one seismograph unit. Returns a result dict."""
|
|
||||||
result: Dict[str, Any] = {
|
|
||||||
"unit_id": unit.id,
|
|
||||||
"action": "checked",
|
|
||||||
"old": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
|
||||||
"new": None,
|
|
||||||
"event_id": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
event = await _get_latest_event(client, unit.id)
|
|
||||||
if not event:
|
|
||||||
result["action"] = "no_event"
|
|
||||||
return result
|
|
||||||
|
|
||||||
sidecar = await _get_event_sidecar(client, event["id"])
|
|
||||||
if not sidecar:
|
|
||||||
result["action"] = "no_sidecar"
|
|
||||||
return result
|
|
||||||
|
|
||||||
device = sidecar.get("device") or {}
|
|
||||||
event_cal = _parse_cal_date(device.get("calibration_date"))
|
|
||||||
if not event_cal:
|
|
||||||
result["action"] = "no_cal_in_sidecar"
|
|
||||||
return result
|
|
||||||
|
|
||||||
result["event_id"] = event["id"]
|
|
||||||
result["new"] = event_cal.isoformat()
|
|
||||||
|
|
||||||
if unit.last_calibrated == event_cal:
|
|
||||||
result["action"] = "already_in_sync"
|
|
||||||
return result
|
|
||||||
|
|
||||||
event_ts = _parse_event_ts(event.get("timestamp"))
|
|
||||||
last_change = (
|
|
||||||
db.query(UnitHistory)
|
|
||||||
.filter(
|
|
||||||
UnitHistory.unit_id == unit.id,
|
|
||||||
UnitHistory.field_name == "last_calibrated",
|
|
||||||
)
|
|
||||||
.order_by(UnitHistory.changed_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if last_change and event_ts and last_change.changed_at > event_ts:
|
|
||||||
result["action"] = "skipped_manual_newer"
|
|
||||||
return result
|
|
||||||
|
|
||||||
old_cal = unit.last_calibrated
|
|
||||||
unit.last_calibrated = event_cal
|
|
||||||
unit.next_calibration_due = event_cal + timedelta(days=_get_cal_interval(db))
|
|
||||||
|
|
||||||
db.add(UnitHistory(
|
|
||||||
unit_id=unit.id,
|
|
||||||
change_type="calibration_status_change",
|
|
||||||
field_name="last_calibrated",
|
|
||||||
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
|
|
||||||
new_value=event_cal.strftime("%Y-%m-%d"),
|
|
||||||
source="sfm_event",
|
|
||||||
notes=f"Synced from event {event['id']}",
|
|
||||||
))
|
|
||||||
|
|
||||||
result["action"] = "updated"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_all_calibrations(db: Optional[Session] = None) -> Dict[str, Any]:
|
|
||||||
"""Sync calibration for every non-retired seismograph.
|
|
||||||
|
|
||||||
If `db` is provided the caller owns the session and commit. Otherwise
|
|
||||||
a session is opened, committed, and closed locally — this is what the
|
|
||||||
scheduled job uses.
|
|
||||||
"""
|
|
||||||
owns_session = db is None
|
|
||||||
if owns_session:
|
|
||||||
db = SessionLocal()
|
|
||||||
|
|
||||||
summary: Dict[str, Any] = {
|
|
||||||
"started_at": datetime.utcnow().isoformat(),
|
|
||||||
"checked": 0,
|
|
||||||
"updated": 0,
|
|
||||||
"skipped_manual_newer": 0,
|
|
||||||
"already_in_sync": 0,
|
|
||||||
"no_event": 0,
|
|
||||||
"no_sidecar": 0,
|
|
||||||
"no_cal_in_sidecar": 0,
|
|
||||||
"errors": 0,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
units = (
|
|
||||||
db.query(RosterUnit)
|
|
||||||
.filter(
|
|
||||||
RosterUnit.retired == False,
|
|
||||||
RosterUnit.device_type == "seismograph",
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
||||||
for unit in units:
|
|
||||||
summary["checked"] += 1
|
|
||||||
try:
|
|
||||||
r = await sync_unit_calibration(db, unit, client)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error syncing calibration for {unit.id}")
|
|
||||||
summary["errors"] += 1
|
|
||||||
summary["results"].append({"unit_id": unit.id, "action": "error", "error": str(e)})
|
|
||||||
continue
|
|
||||||
|
|
||||||
summary["results"].append(r)
|
|
||||||
action = r["action"]
|
|
||||||
if action in summary:
|
|
||||||
summary[action] += 1
|
|
||||||
|
|
||||||
if owns_session:
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if owns_session:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
summary["finished_at"] = datetime.utcnow().isoformat()
|
|
||||||
logger.info(
|
|
||||||
f"Calibration sync done: checked={summary['checked']} "
|
|
||||||
f"updated={summary['updated']} skipped_manual={summary['skipped_manual_newer']} "
|
|
||||||
f"in_sync={summary['already_in_sync']} errors={summary['errors']}"
|
|
||||||
)
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Background scheduler — runs once daily. Modeled on backup_scheduler.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CalibrationSyncScheduler:
|
|
||||||
"""Runs sync_all_calibrations() once per day at a fixed local time."""
|
|
||||||
|
|
||||||
def __init__(self, run_at: str = "03:15"):
|
|
||||||
self.run_at = run_at
|
|
||||||
self.is_running = False
|
|
||||||
self.thread: Optional[threading.Thread] = None
|
|
||||||
self.last_run: Optional[Dict[str, Any]] = None
|
|
||||||
|
|
||||||
def _job_wrapper(self):
|
|
||||||
"""Run the async sync in a fresh event loop (we're on a worker thread)."""
|
|
||||||
try:
|
|
||||||
self.last_run = asyncio.run(sync_all_calibrations())
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Calibration sync job failed: {e}")
|
|
||||||
self.last_run = {"error": str(e), "finished_at": datetime.utcnow().isoformat()}
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if self.is_running:
|
|
||||||
return
|
|
||||||
logger.info(f"Starting calibration sync scheduler (daily at {self.run_at})")
|
|
||||||
schedule.every().day.at(self.run_at).do(self._job_wrapper)
|
|
||||||
self.is_running = True
|
|
||||||
self.thread = threading.Thread(target=self._loop, daemon=True)
|
|
||||||
self.thread.start()
|
|
||||||
|
|
||||||
def _loop(self):
|
|
||||||
while self.is_running:
|
|
||||||
schedule.run_pending()
|
|
||||||
time.sleep(60)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if not self.is_running:
|
|
||||||
return
|
|
||||||
logger.info("Stopping calibration sync scheduler")
|
|
||||||
self.is_running = False
|
|
||||||
if self.thread:
|
|
||||||
self.thread.join(timeout=5)
|
|
||||||
|
|
||||||
def status(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"running": self.is_running,
|
|
||||||
"run_at": self.run_at,
|
|
||||||
"last_run": self.last_run,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_scheduler: Optional[CalibrationSyncScheduler] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_calibration_sync_scheduler() -> CalibrationSyncScheduler:
|
|
||||||
global _scheduler
|
|
||||||
if _scheduler is None:
|
|
||||||
_scheduler = CalibrationSyncScheduler()
|
|
||||||
return _scheduler
|
|
||||||
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.database import get_db_session
|
from backend.database import get_db_session
|
||||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
from backend.services.unit_location import bulk_active_locations
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -138,10 +137,6 @@ def emit_status_snapshot():
|
|||||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
|
|
||||||
# Active-assignment location lookup for all roster units (direct only;
|
|
||||||
# modems inherit from their paired device below in the derive loop).
|
|
||||||
active_locs = bulk_active_locations(db, list(roster.values()))
|
|
||||||
|
|
||||||
# SFM event-forwards are now the primary "last seen" signal for
|
# SFM event-forwards are now the primary "last seen" signal for
|
||||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||||
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
||||||
@@ -230,13 +225,10 @@ def emit_status_snapshot():
|
|||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
# Location for mapping — sourced from active UnitAssignment
|
# Location for mapping
|
||||||
# → MonitoringLocation. Empty for benched / unassigned.
|
"location": r.location or "",
|
||||||
"address": (active_locs.get(unit_id) or {}).get("address") or "",
|
"address": r.address or "",
|
||||||
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "",
|
"coordinates": r.coordinates or "",
|
||||||
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
|
|
||||||
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
|
|
||||||
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Add unexpected emitter-only units ---
|
# --- Add unexpected emitter-only units ---
|
||||||
@@ -275,12 +267,10 @@ def emit_status_snapshot():
|
|||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
# Location fields — unknown units have no assignment
|
# Location fields
|
||||||
|
"location": "",
|
||||||
"address": "",
|
"address": "",
|
||||||
"coordinates": "",
|
"coordinates": "",
|
||||||
"location_name": "",
|
|
||||||
"project_id": "",
|
|
||||||
"location_id": "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Derive modem status from paired devices ---
|
# --- Derive modem status from paired devices ---
|
||||||
@@ -311,11 +301,6 @@ def emit_status_snapshot():
|
|||||||
unit_data["last"] = paired_unit.get("last")
|
unit_data["last"] = paired_unit.get("last")
|
||||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||||
unit_data["derived_from"] = paired_unit_id
|
unit_data["derived_from"] = paired_unit_id
|
||||||
# Inherit deployment location too — modems don't carry
|
|
||||||
# their own UnitAssignment.
|
|
||||||
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
|
|
||||||
if not unit_data.get(k):
|
|
||||||
unit_data[k] = paired_unit.get(k, "")
|
|
||||||
|
|
||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
"""
|
|
||||||
Active-assignment location resolution for roster units.
|
|
||||||
|
|
||||||
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
|
|
||||||
The current source of truth for "where is this unit deployed right now" is the
|
|
||||||
active `UnitAssignment` (assigned_until IS NULL) pointing at a
|
|
||||||
`MonitoringLocation`, which carries the canonical address/coordinates/name.
|
|
||||||
|
|
||||||
Modems don't get their own `UnitAssignment` — they're paired with a
|
|
||||||
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
|
|
||||||
location of its paired device's active assignment.
|
|
||||||
|
|
||||||
Returned dict shape (or None if no active assignment resolvable):
|
|
||||||
{
|
|
||||||
"location_id": "uuid",
|
|
||||||
"project_id": "uuid",
|
|
||||||
"name": "NRL-001",
|
|
||||||
"address": "123 Main St" | None,
|
|
||||||
"coordinates": "34.0522,-118.2437" | None,
|
|
||||||
"via_paired_unit_id": "BE1234" | None, # set only for modems
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
|
|
||||||
return {
|
|
||||||
"location_id": loc.id,
|
|
||||||
"project_id": loc.project_id,
|
|
||||||
"name": loc.name,
|
|
||||||
"address": loc.address or None,
|
|
||||||
"coordinates": loc.coordinates or None,
|
|
||||||
"via_paired_unit_id": via_paired_unit_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
|
|
||||||
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
|
|
||||||
row = (
|
|
||||||
db.query(MonitoringLocation)
|
|
||||||
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
|
|
||||||
.filter(
|
|
||||||
UnitAssignment.unit_id == unit_id,
|
|
||||||
UnitAssignment.assigned_until == None, # noqa: E711
|
|
||||||
)
|
|
||||||
.order_by(UnitAssignment.assigned_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
Resolve the active deployment location for a unit.
|
|
||||||
|
|
||||||
Seismographs / SLMs: their own active UnitAssignment.
|
|
||||||
Modems: follow `deployed_with_unit_id` to the paired device's active
|
|
||||||
assignment (modems don't carry their own assignment).
|
|
||||||
"""
|
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
|
||||||
if unit is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if (unit.device_type or "seismograph") == "modem":
|
|
||||||
paired_id = unit.deployed_with_unit_id
|
|
||||||
if not paired_id:
|
|
||||||
return None
|
|
||||||
loc = _active_location_for_unit_id(db, paired_id)
|
|
||||||
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
|
|
||||||
|
|
||||||
loc = _active_location_for_unit_id(db, unit_id)
|
|
||||||
return _serialize(loc) if loc else None
|
|
||||||
|
|
||||||
|
|
||||||
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
|
|
||||||
"""
|
|
||||||
Resolve active locations for many units in two queries. Use this from
|
|
||||||
snapshot-style loops to avoid N+1 lookups.
|
|
||||||
|
|
||||||
Returns {unit_id: <serialized location dict>} — only populated for units
|
|
||||||
that resolve to an active assignment. Modems are resolved by walking
|
|
||||||
`deployed_with_unit_id` to the paired device's entry in the same map.
|
|
||||||
"""
|
|
||||||
if not units:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
direct_unit_ids = [
|
|
||||||
u.id for u in units
|
|
||||||
if (u.device_type or "seismograph") != "modem"
|
|
||||||
]
|
|
||||||
|
|
||||||
direct: dict[str, MonitoringLocation] = {}
|
|
||||||
if direct_unit_ids:
|
|
||||||
rows = (
|
|
||||||
db.query(UnitAssignment.unit_id, MonitoringLocation)
|
|
||||||
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
|
|
||||||
.filter(
|
|
||||||
UnitAssignment.unit_id.in_(direct_unit_ids),
|
|
||||||
UnitAssignment.assigned_until == None, # noqa: E711
|
|
||||||
)
|
|
||||||
.order_by(UnitAssignment.assigned_at.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
# First row wins per unit_id (most recent assigned_at).
|
|
||||||
for unit_id, loc in rows:
|
|
||||||
direct.setdefault(unit_id, loc)
|
|
||||||
|
|
||||||
out: dict[str, dict] = {
|
|
||||||
uid: _serialize(loc) for uid, loc in direct.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Modems inherit from paired device.
|
|
||||||
for u in units:
|
|
||||||
if (u.device_type or "seismograph") != "modem":
|
|
||||||
continue
|
|
||||||
paired_id = u.deployed_with_unit_id
|
|
||||||
if paired_id and paired_id in direct:
|
|
||||||
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
|
|
||||||
|
|
||||||
return out
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
web-app:
|
terra-view:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
# Client Portal — Design & Build Plan
|
|
||||||
|
|
||||||
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
|
|
||||||
|
|
||||||
A client-facing, **read-only**, **scoped** view into a client's own monitoring
|
|
||||||
data. The first internet-facing-with-real-clients surface in the system. Built
|
|
||||||
*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM
|
|
||||||
reads and Terra-View's report generation — Terra-View stays the UI/business layer;
|
|
||||||
SLMM stays the device layer.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
1. **Read-only.** No device control (start/stop/config), no roster editing, no
|
|
||||||
internal pages. A client can look, never touch.
|
|
||||||
2. **Strictly scoped.** A client only ever sees data for *their* projects. Every
|
|
||||||
portal endpoint verifies ownership server-side — never trust a `unit_id` /
|
|
||||||
`location_id` from the request.
|
|
||||||
3. **Cache-first, no device contention.** Portal live data comes from SLMM's
|
|
||||||
cache (the same cached `/status` + `/history` the internal dashboard uses).
|
|
||||||
No device-hitting calls from the portal — a client can't make us hammer the
|
|
||||||
NL-43. Freshness depends on **keepalive being on** for the client's units.
|
|
||||||
4. **Auth is a swappable gate.** Every route depends on one resolver,
|
|
||||||
`get_current_client()`. M1–M3 ride on an interim signed "magic URL"; M4
|
|
||||||
replaces the resolver's backing without touching routes or templates.
|
|
||||||
|
|
||||||
## The data chain (how a client maps to live data)
|
|
||||||
|
|
||||||
```
|
|
||||||
Client.id
|
|
||||||
└─ Project (client_id == Client.id, status != deleted)
|
|
||||||
└─ MonitoringLocation (project_id, location_type == "sound", removed_at IS NULL)
|
|
||||||
└─ UnitAssignment (location_id, status == "active", device_type == "slm",
|
|
||||||
assigned_until IS NULL or future)
|
|
||||||
└─ unit_id == RosterUnit.id == SLMM unit_id
|
|
||||||
└─ SLMM cached /status + /history (read-only)
|
|
||||||
```
|
|
||||||
|
|
||||||
So the portal shows a client their **locations**, each surfacing the live sound
|
|
||||||
level from whatever SLM is currently assigned there.
|
|
||||||
|
|
||||||
## Data model (new)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Client(Base): # the customer org
|
|
||||||
id, name, slug (unique, URL-safe), contact_email (nullable, for M4),
|
|
||||||
active (bool), created_at
|
|
||||||
|
|
||||||
class ClientAccessToken(Base): # the interim "magic URL" gate
|
|
||||||
id, client_id, token_hash (sha256 — raw shown once on creation),
|
|
||||||
label, created_at, last_used_at, revoked_at (nullable)
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus a migration adding **`Project.client_id`** (nullable FK → `clients.id`).
|
|
||||||
The existing free-text `Project.client_name` stays for display/back-compat;
|
|
||||||
`client_id` is the authoritative link.
|
|
||||||
|
|
||||||
## Auth — the swappable gate
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_current_client(request, db) -> Client: # every /portal route depends on this
|
|
||||||
# M1–M3: read signed `portal_client` cookie -> load Client
|
|
||||||
# M4: same signature, backed by real sessions (magic-link / password)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Interim "magic URL" flow (M1–M3):**
|
|
||||||
- Operator creates a `Client` + an access token → gets a one-time-display URL:
|
|
||||||
`https://…/portal/enter/{token}`.
|
|
||||||
- Client clicks it → token is hashed, looked up (must be un-revoked) →
|
|
||||||
sets a **signed session cookie** (`portal_client`, HMAC via a new `SECRET_KEY`
|
|
||||||
env) → redirects to `/portal`. `last_used_at` updated.
|
|
||||||
- `get_current_client` reads + verifies the cookie thereafter. No valid cookie →
|
|
||||||
"link invalid / expired" page.
|
|
||||||
- Revoke = set `revoked_at`; the link (and any cookie minted from it) stops working.
|
|
||||||
|
|
||||||
Unguessable + revocable + per-person, no email infra or passwords yet — and M4
|
|
||||||
slots in behind the same `get_current_client` with zero route/template churn.
|
|
||||||
|
|
||||||
## Routes (`/portal/*`)
|
|
||||||
|
|
||||||
| Route | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `GET /portal/enter/{token}` | validate token → set cookie → redirect to `/portal` |
|
|
||||||
| `GET /portal` | client's locations overview (status tiles + map) |
|
|
||||||
| `GET /portal/location/{id}` | read-only live panel for that location's SLM |
|
|
||||||
| `GET /portal/api/location/{id}/live` | **scoped** cached `/status` for the location's unit |
|
|
||||||
| `GET /portal/api/location/{id}/history` | **scoped** cached trail for the chart |
|
|
||||||
| `GET /portal/logout` | clear cookie |
|
|
||||||
|
|
||||||
**Scoping helper** (used by every data route):
|
|
||||||
`resolve_client_location(client, location_id, db) -> (location, unit_id)` — raises
|
|
||||||
403 if the location isn't in one of the client's projects. The portal never calls
|
|
||||||
the open `/api/slmm/{unit}/*` endpoints with a client-supplied id.
|
|
||||||
|
|
||||||
## Templates (`templates/portal/`)
|
|
||||||
|
|
||||||
- `portal/base.html` — minimal client-branded shell (no internal sidebar/nav).
|
|
||||||
- `portal/overview.html` — location tiles (live cards mini) + a locations map.
|
|
||||||
- `portal/location.html` — the read-only live panel: cards (Lp/Leq/Lmax/L1/L10),
|
|
||||||
L1/L10 chart, measuring + freshness badge. Reuses the cache-populate JS from the
|
|
||||||
internal panel, **stripped** of start/stop, config, and the device-hitting
|
|
||||||
refresh (cache + 15s auto-poll only).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
### M1 — Live view only *(current)*
|
|
||||||
Interim magic-URL gate; a client sees their locations and per-location read-only
|
|
||||||
live data, all from cache.
|
|
||||||
- [ ] `Client` + `ClientAccessToken` models; `Project.client_id` migration.
|
|
||||||
- [ ] `SECRET_KEY` env + signed-cookie session helper.
|
|
||||||
- [ ] `get_current_client` dependency + `/portal/enter/{token}` + logout.
|
|
||||||
- [ ] Scoping helper `resolve_client_location`.
|
|
||||||
- [ ] `/portal` overview + `/portal/location/{id}` (read-only live panel).
|
|
||||||
- [ ] Scoped `/portal/api/location/{id}/live` + `/history`.
|
|
||||||
- [ ] Portal templates (base, overview, location).
|
|
||||||
- [ ] Minimal admin: create client + mint/revoke access link (small `/admin`
|
|
||||||
page or a script for now).
|
|
||||||
|
|
||||||
### M2 — Dashboard + alerts
|
|
||||||
- Richer client dashboard (multi-location at-a-glance, status rollup).
|
|
||||||
- **Live project map** — upgrade the overview's basic location pins into a real
|
|
||||||
project map: pins colored by measuring/level, popups showing each location's
|
|
||||||
current reading, centered/zoomed to the project. (M1 ships the plain pin map;
|
|
||||||
this makes it a live status map.)
|
|
||||||
- Surface each location's **threshold-alert status** (read-only) + an event/inbox
|
|
||||||
view. Leans on the SLMM alert engine + dispatch.
|
|
||||||
|
|
||||||
### Notes carried from M1
|
|
||||||
- Tile headline metric is **Leq** (energy-average, the sound-monitoring compliance
|
|
||||||
metric) — chosen over the twitchy instantaneous Lp. If clients ever want a
|
|
||||||
different headline (e.g. Lmax for peaks), make it a per-deployment setting.
|
|
||||||
|
|
||||||
### M3 — Reports
|
|
||||||
- Client-facing list + download of the daily baseline-comparison reports.
|
|
||||||
- Depends on the FTP report pipeline (`feat/ftp-report-pipeline`) landing and
|
|
||||||
being wired into the portal's scoped routes.
|
|
||||||
|
|
||||||
### M4 — Full auth system
|
|
||||||
- Replace the interim token behind `get_current_client` with a real auth design:
|
|
||||||
magic-link (passwordless email) and/or accounts, proper sessions, password
|
|
||||||
reset, and likely auth for the *internal* app too. Reverse-proxy + TLS posture.
|
|
||||||
|
|
||||||
## Going to prod (M1)
|
|
||||||
|
|
||||||
1. **Run the migration on the prod DB** — `migrate_add_client_portal.py` adds
|
|
||||||
`projects.client_id` (the new tables auto-create via `create_all`). Skipping it
|
|
||||||
500s anything that touches `Project.client_id`. This is the silent killer.
|
|
||||||
```bash
|
|
||||||
docker compose exec web-app python3 backend/migrate_add_client_portal.py
|
|
||||||
```
|
|
||||||
2. **Set a real `SECRET_KEY`** in the prod env (compose). The portal signs session
|
|
||||||
cookies with it; the insecure dev default (it logs a warning at boot) is
|
|
||||||
forgeable. Non-negotiable for an internet-facing portal.
|
|
||||||
3. **SLMM_BASE_URL** — prod base compose already points at `:8100` (correct; the
|
|
||||||
`:9100` mismatch is a dev-only override quirk). For full live data (L1/L10 +
|
|
||||||
chart backfill) prod SLMM must be on the `dev` build with its migrations
|
|
||||||
(`migrate_add_ln_percentiles`, `migrate_add_monitor_enabled`) and **keepalive on**
|
|
||||||
for the client's units — otherwise the portal degrades gracefully (cards show
|
|
||||||
`--`, chart empty), it just isn't fully populated.
|
|
||||||
4. **Seed real clients** with the CLI (`backend/portal_admin.py`): `create-client`
|
|
||||||
→ `link-project` (a real sound project with an active SLM assignment) →
|
|
||||||
`mint-link` → send the client the printed URL (shown once).
|
|
||||||
5. **Exposure** — portal routes are auth-gated, but port 8001 still serves the
|
|
||||||
whole *internal* app with no auth. Before real clients are on it, the portal
|
|
||||||
should sit behind the reverse proxy with only `/portal/*` exposed (or the app
|
|
||||||
restricted). This is the point where the parked reverse-proxy/TLS work becomes
|
|
||||||
load-bearing.
|
|
||||||
|
|
||||||
## Security notes
|
|
||||||
|
|
||||||
- Portal is auth-gated from day one (even the interim gate) — never wide-open like
|
|
||||||
the internal app.
|
|
||||||
- All scoping enforced server-side; client-supplied ids are always re-checked.
|
|
||||||
- `SECRET_KEY` must be a real secret in prod (env, not committed).
|
|
||||||
- Cookies: `HttpOnly`, `SameSite=Lax`, `Secure` once behind TLS.
|
|
||||||
- Tokens stored hashed; raw shown once. Revocation is immediate.
|
|
||||||
|
|
||||||
## Security hardening backlog ("Fest 2026")
|
|
||||||
|
|
||||||
The to-do for the dedicated hardening pass, roughly highest-impact first. Until
|
|
||||||
then the portal runs on security-by-obscurity (open port + interim links) — fine
|
|
||||||
for a not-in-use demo, not for real clients.
|
|
||||||
|
|
||||||
**Exposure (the big one):** port 8001 serves the *entire operator app* (roster,
|
|
||||||
projects, `/admin/*`, device config, the SLMM proxy) with **zero auth**, so an
|
|
||||||
open port exposes far more than the read-only portal.
|
|
||||||
- [ ] Reverse proxy (NPM/Caddy/Nginx) in front, exposing **only `/portal/*`** to
|
|
||||||
the internet; keep the operator app reachable on the LAN only.
|
|
||||||
- [ ] TLS everywhere (Let's Encrypt). Then set portal cookies `Secure`.
|
|
||||||
- [ ] Don't port-forward the raw app; if a quick gate is wanted before M4, an
|
|
||||||
auth proxy (Authelia / Authentik) can front the portal without writing auth.
|
|
||||||
|
|
||||||
**Config musts:**
|
|
||||||
- [ ] Set a real `SECRET_KEY` env (signs session cookies; default is public).
|
|
||||||
- [ ] `PORTAL_OPEN_LINKS=false` in any internet-facing env (it defaults off now).
|
|
||||||
|
|
||||||
**M4 — real auth** (replaces the interim token behind `get_current_client`):
|
|
||||||
- [ ] Magic-link email and/or accounts; proper sessions + password reset.
|
|
||||||
- [ ] Authenticate the **operator** app too (it currently has none).
|
|
||||||
- [ ] Gate the operator-only endpoints that are presently unauthenticated:
|
|
||||||
`/projects/{id}/portal-preview`, `/projects/{id}/portal-link*`,
|
|
||||||
`/portal/open/*`.
|
|
||||||
|
|
||||||
**Smaller items from the pre-merge code review:**
|
|
||||||
- [ ] Keepalive isn't auto-turned-off when the last alert rule on a unit is
|
|
||||||
deleted (intentional "never auto-off"; revisit if it wastes cellular).
|
|
||||||
- [ ] Consider rate-limiting the scoped portal endpoints once public.
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Terra-View Roadmap
|
|
||||||
|
|
||||||
Living document — captures known deferred work, in-flight initiatives, and longer-term ideas.
|
|
||||||
Bump items up/down or strike them through as priorities shift. Source of truth for "what's next"
|
|
||||||
should be this file plus the `## Current Development Focus` block in `CLAUDE.md`.
|
|
||||||
|
|
||||||
Last updated: 2026-06-05 (Terra-View v0.13.3)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## In Flight
|
|
||||||
|
|
||||||
Work that's started or has obvious next steps in the code.
|
|
||||||
|
|
||||||
- **SFM Integration Phase 2 — device control** — expose `/device/*` (start, stop, erase, push-config)
|
|
||||||
through the Terra-View proxy. Blocked on SFM growing an auth layer; placeholder TODOs already in
|
|
||||||
`backend/services/device_controller.py` (lines 73, 109, 207, 282, 582).
|
|
||||||
- **Calibration sync from SFM events** — done in v0.13.x. Daily 03:15 job + Settings "Sync now" button.
|
|
||||||
Future: surface "last sync" timestamp on unit detail; per-unit "sync this one" action.
|
|
||||||
- **Synology NAS deployment** — doc lives at `docs/SYNOLOGY_DEPLOYMENT.md`. Need to actually deploy
|
|
||||||
+ write up what tripped us up vs. the doc's expectations.
|
|
||||||
|
|
||||||
## Near-Term
|
|
||||||
|
|
||||||
Concrete things scoped but not started.
|
|
||||||
|
|
||||||
- **Migrate GPS coord parse in `photos.py`** — currently writes to dead `RosterUnit.coordinates`
|
|
||||||
field. Should write to the active `MonitoringLocation` instead (matches the location-as-truth
|
|
||||||
refactor done elsewhere). Helper: `backend/services/unit_location.py`.
|
|
||||||
- **Phase 3 — drag-to-resize deployment bars** on the fleet-wide deployment-history Gantt
|
|
||||||
(`/tools/deployment-history`). Phase 2 (the calendar + Gantt tabs) shipped in v0.12.0.
|
|
||||||
- **Phase 5c — swap-detection daily job** — placeholder card already in `templates/tools.html:162`.
|
|
||||||
Auto-detects unit swaps in the field (BE12345 → BE67890 at the same project+location) from
|
|
||||||
operator-typed metadata. Pairs with a notification inbox.
|
|
||||||
- **Geocoding for address strings** — TODO in `templates/dashboard.html:913`. Lets locations without
|
|
||||||
explicit coordinates still appear on maps.
|
|
||||||
- **ModemManager backend** — `backend/routers/modem_dashboard.py:279` has a TODO for querying a real
|
|
||||||
modem backend. Currently the modem dashboard is mostly read-only metadata.
|
|
||||||
|
|
||||||
## Medium-Term
|
|
||||||
|
|
||||||
Bigger features, sketched but not designed in detail.
|
|
||||||
|
|
||||||
- **Alerting** — email/SMS for missing units, calibration-expiring-soon, sync failures.
|
|
||||||
README's "Future Enhancements" has had this for a while; would pair well with the existing
|
|
||||||
`UserPreferences` thresholds.
|
|
||||||
- **Multi-user auth** — currently single-tenant, no login. Probably the prerequisite for any
|
|
||||||
cloud-hosted multi-customer deployment.
|
|
||||||
- **Notification inbox** — central place for swap-detection alerts, sync errors, calibration
|
|
||||||
warnings, FT-flag review queue, etc.
|
|
||||||
- **Audit log UI** — `UnitHistory` already records everything; expose a filterable view.
|
|
||||||
|
|
||||||
## Long-Term / Wishlist
|
|
||||||
|
|
||||||
Speculative. Promote up the list once there's a concrete need.
|
|
||||||
|
|
||||||
- PostgreSQL backend for larger deployments (SQLite is fine for now)
|
|
||||||
- Advanced filtering / saved searches on roster + events
|
|
||||||
- Export roster in additional formats (XLSX, GeoJSON)
|
|
||||||
- Public-facing project status pages (read-only, share-link gated)
|
|
||||||
- SLM module parity with seismographs — modal-based event/measurement detail similar to SFM modal
|
|
||||||
- Weather station / accelerometer / GPS tracker modules (new device-type modules following the
|
|
||||||
SLMM pattern — see `CLAUDE.md` → "Adding a New Device Type Module")
|
|
||||||
|
|
||||||
## Done / Reference
|
|
||||||
|
|
||||||
For shipped items, see `CHANGELOG.md`. For architecture decisions, see `CLAUDE.md`.
|
|
||||||
@@ -9,4 +9,3 @@ Pillow==10.1.0
|
|||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
rapidfuzz==3.10.1
|
rapidfuzz==3.10.1
|
||||||
schedule==1.2.2
|
|
||||||
|
|||||||
@@ -42,18 +42,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Monitoring (keepalive) -->
|
|
||||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-1">Live Monitoring (keepalive)</h2>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
Keepalive runs the 1 Hz DOD feed 24/7 (even with no viewer), which powers the live-chart
|
|
||||||
trail and continuous threshold alerts. Toggling persists and survives restarts.
|
|
||||||
</p>
|
|
||||||
<div id="monitor-list" class="text-sm">
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Raw API tester -->
|
<!-- Raw API tester -->
|
||||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
||||||
@@ -144,60 +132,7 @@ async function sendRaw() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMonitors() {
|
|
||||||
const el = document.getElementById('monitor-list');
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/slmm/roster');
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
const d = await r.json();
|
|
||||||
const devices = d.devices || [];
|
|
||||||
if (!devices.length) {
|
|
||||||
el.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No devices configured.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = devices.map(dev => {
|
|
||||||
const on = !!dev.monitor_enabled;
|
|
||||||
const reach = dev.status ? dev.status.is_reachable : null;
|
|
||||||
const reachDot = reach === false
|
|
||||||
? '<span class="w-2 h-2 rounded-full bg-red-500 inline-block" title="unreachable"></span>'
|
|
||||||
: '<span class="w-2 h-2 rounded-full bg-green-500 inline-block" title="reachable"></span>';
|
|
||||||
return `
|
|
||||||
<div class="flex items-center justify-between border-b border-gray-100 dark:border-gray-700 py-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${reachDot}
|
|
||||||
<span class="font-mono text-gray-900 dark:text-white">${_esc(dev.unit_id)}</span>
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(dev.host)}:${_esc(dev.tcp_port)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-xs font-medium px-2 py-0.5 rounded ${on
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400'}">${on ? '24/7 ON' : 'OFF'}</span>
|
|
||||||
<button onclick="toggleMonitor('${_esc(dev.unit_id)}', ${!on})"
|
|
||||||
class="px-3 py-1 text-xs rounded text-white ${on
|
|
||||||
? 'bg-red-600 hover:bg-red-700' : 'bg-seismo-orange hover:bg-orange-600'}">
|
|
||||||
${on ? 'Disable' : 'Enable'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) {
|
|
||||||
el.innerHTML = `<p class="text-red-600 dark:text-red-400">Failed to load devices: ${_esc(e.message)}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleMonitor(unitId, enable) {
|
|
||||||
const action = enable ? 'start' : 'stop';
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/monitor/${action}`, { method: 'POST' });
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
await loadMonitors();
|
|
||||||
} catch (e) {
|
|
||||||
alert('Toggle failed: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSlmmOverview();
|
loadSlmmOverview();
|
||||||
loadMonitors();
|
setInterval(loadSlmmOverview, 30000);
|
||||||
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+51
-86
@@ -150,55 +150,46 @@ setInterval(_refreshPendingDeployBanner, 30000);
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 card-content" id="fleet-summary-content">
|
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||||
<!-- Seismographs -->
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
||||||
|
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||||
</div>
|
</div>
|
||||||
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
|
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
<div class="flex justify-between items-center mb-2">
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
|
||||||
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
|
||||||
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sound Level Meters -->
|
|
||||||
<div>
|
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||||
</div>
|
|
||||||
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
|
||||||
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
|
||||||
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||||
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
@@ -637,14 +628,9 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
fleetMarkers = [];
|
fleetMarkers = [];
|
||||||
|
|
||||||
// Get deployed units with coordinates that pass the filter.
|
// Get deployed units with coordinates that pass the filter
|
||||||
// Modems are not plotted — they inherit the paired device's location,
|
|
||||||
// which would just stack a duplicate marker on the same pin.
|
|
||||||
const deployedUnits = Object.entries(allUnits || {})
|
const deployedUnits = Object.entries(allUnits || {})
|
||||||
.filter(([_, u]) => u.deployed
|
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
|
||||||
&& u.coordinates
|
|
||||||
&& (u.device_type || 'seismograph') !== 'modem'
|
|
||||||
&& unitPassesFilter(u));
|
|
||||||
|
|
||||||
if (deployedUnits.length === 0) {
|
if (deployedUnits.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -686,12 +672,10 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
// Popup with device type
|
// Popup with device type
|
||||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
const deviceLabel = getDeviceTypeLabel(deviceType);
|
||||||
|
|
||||||
const locName = unit.location_name || '';
|
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<h3 class="font-bold text-lg">${id}</h3>
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
||||||
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
|
|
||||||
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||||
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
||||||
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
||||||
@@ -799,51 +783,32 @@ function updateDashboard(event) {
|
|||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
|
// ===== Fleet summary numbers (always unfiltered) =====
|
||||||
// Deployed = unit has an active UnitAssignment (location_id set by
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
// the snapshot helper). Benched = no active assignment.
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
// Retired, out-for-calibration, and roster-unknown units (emitters
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
// not in the roster) are excluded from totals.
|
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
||||||
const counts = {
|
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||||
seismograph: { total: 0, deployed: 0, benched: 0 },
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
sound_level_meter: { total: 0, deployed: 0, benched: 0 },
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
};
|
|
||||||
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
|
|
||||||
const unknownIds = new Set(Object.keys(data.unknown || {}));
|
|
||||||
|
|
||||||
Object.entries(data.units || {}).forEach(([uid, unit]) => {
|
// ===== Device type counts (always unfiltered) =====
|
||||||
if (unit.retired || unit.out_for_calibration) return;
|
let seismoCount = 0;
|
||||||
if (unknownIds.has(uid)) return;
|
let slmCount = 0;
|
||||||
const dt = unit.device_type || 'seismograph';
|
let modemCount = 0;
|
||||||
const bucket = counts[dt];
|
Object.values(data.units || {}).forEach(unit => {
|
||||||
if (!bucket) return; // skip modems and anything else
|
if (unit.retired) return; // Don't count retired units
|
||||||
|
const deviceType = unit.device_type || 'seismograph';
|
||||||
bucket.total++;
|
if (deviceType === 'seismograph') {
|
||||||
if (unit.location_id) {
|
seismoCount++;
|
||||||
bucket.deployed++;
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
} else {
|
slmCount++;
|
||||||
bucket.benched++;
|
} else if (deviceType === 'modem') {
|
||||||
}
|
modemCount++;
|
||||||
|
|
||||||
// Status tally only for seismographs + SLMs that are actually
|
|
||||||
// deployed (assigned). Mirrors the per-device buckets so the
|
|
||||||
// sum matches.
|
|
||||||
if (unit.location_id) {
|
|
||||||
if (unit.status === 'OK') monitoredOk++;
|
|
||||||
else if (unit.status === 'Pending') monitoredPending++;
|
|
||||||
else if (unit.status === 'Missing') monitoredMissing++;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.getElementById('seismo-count').textContent = seismoCount;
|
||||||
document.getElementById('seismo-count').textContent = counts.seismograph.total;
|
document.getElementById('slm-count').textContent = slmCount;
|
||||||
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
|
|
||||||
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
|
|
||||||
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
|
|
||||||
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
|
|
||||||
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
|
|
||||||
document.getElementById('status-ok').textContent = monitoredOk;
|
|
||||||
document.getElementById('status-pending').textContent = monitoredPending;
|
|
||||||
document.getElementById('status-missing').textContent = monitoredMissing;
|
|
||||||
|
|
||||||
// ===== Apply filters and render map + alerts =====
|
// ===== Apply filters and render map + alerts =====
|
||||||
renderFilteredDashboard(data);
|
renderFilteredDashboard(data);
|
||||||
|
|||||||
@@ -2,14 +2,7 @@
|
|||||||
{% if units %}
|
{% if units %}
|
||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
||||||
<div class="absolute top-3 right-3 flex gap-2 z-10">
|
<div class="absolute top-3 right-3 flex gap-2">
|
||||||
<button onclick="event.preventDefault(); event.stopPropagation(); refreshSlmUnit('{{ unit.id }}', this);"
|
|
||||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
|
||||||
title="Refresh {{ unit.id }} from device">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
||||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
title="View live chart">
|
title="View live chart">
|
||||||
@@ -27,44 +20,41 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/slm/{{ unit.id }}" class="block pr-24">
|
<a href="/slm/{{ unit.id }}" class="block">
|
||||||
<div class="min-w-0">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
<div class="flex items-center gap-2">
|
||||||
{% if unit.slm_model %}
|
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
{% if unit.slm_model %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if unit.address %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
||||||
|
{% elif unit.location %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if unit.address %}
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
{% if unit.retired %}
|
||||||
{% elif unit.location %}
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
{% elif not unit.deployed %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
||||||
|
{% elif unit.measurement_state == "Start" %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
||||||
|
{% elif unit.is_recent %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status badge + last-check on one line (moved off the top-right so it
|
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
no longer collides with the refresh/chart/gear action icons). -->
|
{% if unit.slm_last_check %}
|
||||||
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||||
{% if unit.retired %}
|
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
|
||||||
{% elif not unit.deployed %}
|
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
|
||||||
{% elif unit.measurement_state in ["Start", "Measure"] %}
|
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
|
||||||
{% elif unit.is_recent %}
|
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
No recent check-in
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{% if unit.cache_last_seen %}
|
|
||||||
Last check: {{ unit.cache_last_seen|local_datetime }}
|
|
||||||
{% elif unit.slm_last_check %}
|
|
||||||
Last check: {{ unit.slm_last_check|local_datetime }}
|
|
||||||
{% else %}
|
|
||||||
No recent check-in
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,8 +143,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Stop Live Stream
|
Stop Live Stream
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,17 +173,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||||
<p id="live-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln1_label %}{{ current_status.ln1_label }}{% else %}L1{% endif %}</p>
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
||||||
<p id="live-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
{% if current_status and current_status.ln1 %}{{ current_status.ln1 }}{% else %}--{% endif %}
|
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||||
<p id="live-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln2_label %}{{ current_status.ln2_label }}{% else %}L10{% endif %}</p>
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
||||||
<p id="live-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
{% if current_status and current_status.ln2 %}{{ current_status.ln2 }}{% else %}--{% endif %}
|
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,24 +432,6 @@ function initializeChart() {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0
|
pointRadius: 0
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'L1',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(139, 92, 246)',
|
|
||||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'L10',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(245, 158, 11)',
|
|
||||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -513,37 +493,7 @@ if (typeof window.currentWebSocket === 'undefined') {
|
|||||||
window.currentWebSocket = null;
|
window.currentWebSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backfill the chart with the recent DOD trail so it opens with context.
|
function initLiveDataStream(unitId) {
|
||||||
async function backfillChart(unitId) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
|
|
||||||
if (!r.ok) return;
|
|
||||||
const d = await r.json();
|
|
||||||
const readings = d.readings || [];
|
|
||||||
if (!window.chartData) return;
|
|
||||||
for (const row of readings) {
|
|
||||||
// Trail timestamps are naive UTC; append 'Z' so they convert to local
|
|
||||||
// consistently with the live frames (which use local Date.now()).
|
|
||||||
window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
|
||||||
window.chartData.lp.push(parseFloat(row.lp || 0));
|
|
||||||
window.chartData.leq.push(parseFloat(row.leq || 0));
|
|
||||||
window.chartData.ln1.push(parseFloat(row.ln1 || 0));
|
|
||||||
window.chartData.ln2.push(parseFloat(row.ln2 || 0));
|
|
||||||
}
|
|
||||||
if (window.liveChart) {
|
|
||||||
window.liveChart.data.labels = window.chartData.timestamps;
|
|
||||||
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
|
||||||
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
|
||||||
window.liveChart.data.datasets[2].data = window.chartData.ln1;
|
|
||||||
window.liveChart.data.datasets[3].data = window.chartData.ln2;
|
|
||||||
window.liveChart.update('none');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Chart backfill failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initLiveDataStream(unitId) {
|
|
||||||
// Close existing connection if any
|
// Close existing connection if any
|
||||||
if (window.currentWebSocket) {
|
if (window.currentWebSocket) {
|
||||||
window.currentWebSocket.close();
|
window.currentWebSocket.close();
|
||||||
@@ -554,24 +504,17 @@ async function initLiveDataStream(unitId) {
|
|||||||
window.chartData.timestamps = [];
|
window.chartData.timestamps = [];
|
||||||
window.chartData.lp = [];
|
window.chartData.lp = [];
|
||||||
window.chartData.leq = [];
|
window.chartData.leq = [];
|
||||||
window.chartData.ln1 = [];
|
|
||||||
window.chartData.ln2 = [];
|
|
||||||
}
|
}
|
||||||
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||||
window.liveChart.data.labels = [];
|
window.liveChart.data.labels = [];
|
||||||
window.liveChart.data.datasets.forEach(ds => ds.data = []);
|
window.liveChart.data.datasets[0].data = [];
|
||||||
|
window.liveChart.data.datasets[1].data = [];
|
||||||
window.liveChart.update();
|
window.liveChart.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the chart with recent history BEFORE opening the live socket, so live
|
// WebSocket URL for SLMM backend via proxy
|
||||||
// frames append after the backfill (right order) and the chart isn't blank.
|
|
||||||
await backfillChart(unitId);
|
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy.
|
|
||||||
// /monitor = the shared fan-out DOD feed (many viewers, one device connection,
|
|
||||||
// and it carries L1/L10 which the DRD /stream cannot).
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||||
|
|
||||||
window.currentWebSocket = new WebSocket(wsUrl);
|
window.currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -587,11 +530,7 @@ async function initLiveDataStream(unitId) {
|
|||||||
window.currentWebSocket.onmessage = function(event) {
|
window.currentWebSocket.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
// The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a
|
console.log('WebSocket data received:', data);
|
||||||
// 'feed_status' on each frame. Reflect status, but don't let a heartbeat
|
|
||||||
// or an 'unreachable' frame blank the cards / spike the chart with zeros.
|
|
||||||
updateFeedStatus(data.feed_status);
|
|
||||||
if (data.heartbeat || data.feed_status === 'unreachable') return;
|
|
||||||
updateLiveMetrics(data);
|
updateLiveMetrics(data);
|
||||||
updateLiveChart(data);
|
updateLiveChart(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -620,21 +559,6 @@ function stopLiveDataStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reflect device reachability from the monitor feed's feed_status. Safe no-op
|
|
||||||
// if the badge element isn't on the page.
|
|
||||||
function updateFeedStatus(status) {
|
|
||||||
const el = document.getElementById('live-feed-status');
|
|
||||||
if (!el || status == null) return;
|
|
||||||
if (status === 'unreachable') {
|
|
||||||
el.textContent = 'Device offline';
|
|
||||||
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
|
||||||
} else {
|
|
||||||
el.textContent = 'Live';
|
|
||||||
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
|
|
||||||
}
|
|
||||||
el.style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update metrics display
|
// Update metrics display
|
||||||
function updateLiveMetrics(data) {
|
function updateLiveMetrics(data) {
|
||||||
if (document.getElementById('live-lp')) {
|
if (document.getElementById('live-lp')) {
|
||||||
@@ -646,20 +570,11 @@ function updateLiveMetrics(data) {
|
|||||||
if (document.getElementById('live-lmax')) {
|
if (document.getElementById('live-lmax')) {
|
||||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||||
}
|
}
|
||||||
// Only update Ln values when the frame actually carries them. DRD stream
|
if (document.getElementById('live-lmin')) {
|
||||||
// frames omit percentiles (DOD-only), so without this guard a live stream
|
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||||
// would blank L1/L10 over the values rendered from the cached DOD snapshot.
|
|
||||||
if (data.ln1 != null && document.getElementById('live-ln1')) {
|
|
||||||
document.getElementById('live-ln1').textContent = data.ln1;
|
|
||||||
}
|
}
|
||||||
if (data.ln1_label && document.getElementById('live-ln1-label')) {
|
if (document.getElementById('live-lpeak')) {
|
||||||
document.getElementById('live-ln1-label').textContent = data.ln1_label;
|
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
||||||
}
|
|
||||||
if (data.ln2 != null && document.getElementById('live-ln2')) {
|
|
||||||
document.getElementById('live-ln2').textContent = data.ln2;
|
|
||||||
}
|
|
||||||
if (data.ln2_label && document.getElementById('live-ln2-label')) {
|
|
||||||
document.getElementById('live-ln2-label').textContent = data.ln2_label;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,9 +583,7 @@ if (typeof window.chartData === 'undefined') {
|
|||||||
window.chartData = {
|
window.chartData = {
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: [],
|
leq: []
|
||||||
ln1: [],
|
|
||||||
ln2: []
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,17 +593,12 @@ function updateLiveChart(data) {
|
|||||||
window.chartData.timestamps.push(now.toLocaleTimeString());
|
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||||
window.chartData.lp.push(parseFloat(data.lp || 0));
|
window.chartData.lp.push(parseFloat(data.lp || 0));
|
||||||
window.chartData.leq.push(parseFloat(data.leq || 0));
|
window.chartData.leq.push(parseFloat(data.leq || 0));
|
||||||
window.chartData.ln1.push(parseFloat(data.ln1 || 0));
|
|
||||||
window.chartData.ln2.push(parseFloat(data.ln2 || 0));
|
|
||||||
|
|
||||||
// Keep a rolling window large enough to hold the ~2h backfill (one point/min)
|
// Keep only last 60 data points
|
||||||
// plus a good run of live points before the oldest scroll off.
|
if (window.chartData.timestamps.length > 60) {
|
||||||
if (window.chartData.timestamps.length > 600) {
|
|
||||||
window.chartData.timestamps.shift();
|
window.chartData.timestamps.shift();
|
||||||
window.chartData.lp.shift();
|
window.chartData.lp.shift();
|
||||||
window.chartData.leq.shift();
|
window.chartData.leq.shift();
|
||||||
window.chartData.ln1.shift();
|
|
||||||
window.chartData.ln2.shift();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update chart if available
|
// Update chart if available
|
||||||
@@ -698,8 +606,6 @@ function updateLiveChart(data) {
|
|||||||
window.liveChart.data.labels = window.chartData.timestamps;
|
window.liveChart.data.labels = window.chartData.timestamps;
|
||||||
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||||
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||||
window.liveChart.data.datasets[2].data = window.chartData.ln1;
|
|
||||||
window.liveChart.data.datasets[3].data = window.chartData.ln2;
|
|
||||||
window.liveChart.update('none');
|
window.liveChart.update('none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
|
|||||||
if (typeof checkFTPStatus === 'function') {
|
if (typeof checkFTPStatus === 'function') {
|
||||||
checkFTPStatus(unitId);
|
checkFTPStatus(unitId);
|
||||||
}
|
}
|
||||||
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
if (typeof htmx !== 'undefined') {
|
||||||
htmx.trigger('#slm-list', 'load');
|
htmx.trigger('#slm-list', 'load');
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -604,10 +604,8 @@ async function toggleSLMDeployed() {
|
|||||||
successDiv.classList.remove('hidden');
|
successDiv.classList.remove('hidden');
|
||||||
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
||||||
|
|
||||||
// Refresh any SLM list on the page (only if one is actually present —
|
// Refresh any SLM list on the page
|
||||||
// the detail/dashboard pages have no #slm-list, and htmx.trigger on a
|
if (typeof htmx !== 'undefined') {
|
||||||
// null target throws "can't access property dispatchEvent, e is null").
|
|
||||||
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
|
||||||
htmx.trigger('#slm-list', 'load');
|
htmx.trigger('#slm-list', 'load');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends "portal/base.html" %}
|
|
||||||
{% block title %}Access{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="max-w-md mx-auto mt-20 text-center reveal">
|
|
||||||
<div class="panel inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6">
|
|
||||||
<svg class="w-7 h-7 text-[var(--text-dim)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
||||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{% if reason == "invalid" %}
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight mb-2">This link isn't valid</h1>
|
|
||||||
<p class="text-[var(--text-dim)] text-sm leading-relaxed">The access link is expired or has been revoked.<br>Please contact TMI for a new link.</p>
|
|
||||||
{% else %}
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight mb-2">Access link required</h1>
|
|
||||||
<p class="text-[var(--text-dim)] text-sm leading-relaxed">Open the monitoring link TMI sent you to view your locations.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="h-full">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{% block title %}Monitoring{% endblock %} · TMI</title>
|
|
||||||
|
|
||||||
<!-- apply saved theme before paint (no flash); light is the default -->
|
|
||||||
<script>(function(){var t=localStorage.getItem('portal-theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
theme: { extend: {
|
|
||||||
colors: { seismo: { orange: '#f48b1c', navy: '#142a66', burgundy: '#7d234d' } },
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['"Hanken Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
|
||||||
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
|
||||||
},
|
|
||||||
} }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
|
||||||
<meta name="theme-color" content="#eef2f9">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ---- dark (default) ---- */
|
|
||||||
:root {
|
|
||||||
--bg: #080b14;
|
|
||||||
--grid: rgba(124, 146, 188, 0.045);
|
|
||||||
--aurora-1: rgba(20, 42, 102, 0.55);
|
|
||||||
--aurora-2: rgba(125, 35, 77, 0.18);
|
|
||||||
--text: #e7ecf6;
|
|
||||||
--text-dim: #8c98b0;
|
|
||||||
--border: rgba(124, 146, 188, 0.14);
|
|
||||||
--border-bright: rgba(168, 188, 224, 0.30);
|
|
||||||
--panel-a: rgba(24, 33, 54, 0.72);
|
|
||||||
--panel-b: rgba(12, 18, 31, 0.62);
|
|
||||||
--panel-inset: rgba(255, 255, 255, 0.05);
|
|
||||||
--panel-shadow: 0 22px 48px -28px rgba(0, 0, 0, 0.85);
|
|
||||||
--header-bg: rgba(8, 11, 20, 0.72);
|
|
||||||
--accent: #f48b1c;
|
|
||||||
--accent-glow: rgba(244, 139, 28, 0.40);
|
|
||||||
--lvl-ok: #34d399; --lvl-warn: #fbbf24; --lvl-bad: #f87171;
|
|
||||||
--m-lp: #60a5fa; --m-lmax: #f87171; --m-l1: #c084fc; --m-l10: #fbbf24;
|
|
||||||
}
|
|
||||||
/* ---- light (cool) — solid cards on a cool ground ---- */
|
|
||||||
html[data-theme="light"] {
|
|
||||||
--bg: #eef2f9; /* cool light */
|
|
||||||
--grid: rgba(20, 42, 102, 0.05); /* cool faint grid */
|
|
||||||
--aurora-1: rgba(120, 150, 220, 0.18); /* cool wash */
|
|
||||||
--aurora-2: rgba(244, 139, 28, 0.08); /* faint brand accent */
|
|
||||||
--text: #16203a; /* cool navy ink */
|
|
||||||
--text-dim: #5d6b86; /* cool muted */
|
|
||||||
--border: rgba(20, 42, 102, 0.13);
|
|
||||||
--border-bright: rgba(20, 42, 102, 0.18);
|
|
||||||
--panel-a: #ffffff; /* solid — kept from the un-ghosting pass */
|
|
||||||
--panel-b: #f7f9fc;
|
|
||||||
--panel-inset: rgba(255, 255, 255, 0.9);
|
|
||||||
--panel-shadow: 0 14px 30px -16px rgba(40, 55, 95, 0.22), 0 2px 6px -2px rgba(40, 55, 95, 0.07);
|
|
||||||
--header-bg: rgba(238, 242, 249, 0.85);
|
|
||||||
--lvl-ok: #16a34a; --lvl-warn: #d97706; --lvl-bad: #dc2626;
|
|
||||||
--m-lp: #2563eb; --m-lmax: #dc2626; --m-l1: #9333ea; --m-l10: #d97706;
|
|
||||||
}
|
|
||||||
/* On light, the hover-lift shadow wants cool depth (the dark one vanishes on light). */
|
|
||||||
html[data-theme="light"] .panel-hover:hover {
|
|
||||||
box-shadow: 0 22px 44px -20px rgba(40, 55, 95, 0.26), 0 0 0 1px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text);
|
|
||||||
font-family: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
font-feature-settings: "ss01";
|
|
||||||
background-color: var(--bg);
|
|
||||||
background-image:
|
|
||||||
radial-gradient(1100px 560px at 50% -12%, var(--aurora-1), transparent 68%),
|
|
||||||
radial-gradient(700px 400px at 88% 8%, var(--aurora-2), transparent 70%),
|
|
||||||
linear-gradient(var(--grid) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
|
||||||
background-size: auto, auto, 46px 46px, 46px 46px;
|
|
||||||
background-attachment: fixed;
|
|
||||||
transition: background-color .3s ease, color .3s ease;
|
|
||||||
}
|
|
||||||
::selection { background: rgba(244, 139, 28, 0.30); }
|
|
||||||
|
|
||||||
.font-mono, .reading { font-family: "IBM Plex Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
position: relative;
|
|
||||||
background: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
box-shadow: 0 1px 0 var(--panel-inset) inset, var(--panel-shadow);
|
|
||||||
}
|
|
||||||
.panel::before {
|
|
||||||
content: ''; position: absolute; inset: 0 0 auto 0; height: 1px;
|
|
||||||
background: linear-gradient(90deg, transparent, var(--border-bright), transparent);
|
|
||||||
}
|
|
||||||
.panel-hover { transition: transform .22s ease, border-color .22s ease, box-shadow .22s ease; }
|
|
||||||
.panel-hover:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
border-color: rgba(244, 139, 28, 0.55);
|
|
||||||
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--accent-glow);
|
|
||||||
}
|
|
||||||
.hairline { border-top: 1px solid var(--border); }
|
|
||||||
|
|
||||||
/* metric accent colors (flip per theme) */
|
|
||||||
.c-lp { color: var(--m-lp); } .c-lmax { color: var(--m-lmax); }
|
|
||||||
.c-l1 { color: var(--m-l1); } .c-l10 { color: var(--m-l10); }
|
|
||||||
|
|
||||||
.live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 0 var(--accent-glow); animation: pulse 2.2s infinite; }
|
|
||||||
@keyframes pulse { 0% { box-shadow: 0 0 0 0 var(--accent-glow); } 70% { box-shadow: 0 0 0 9px rgba(244, 139, 28, 0); } 100% { box-shadow: 0 0 0 0 rgba(244, 139, 28, 0); } }
|
|
||||||
|
|
||||||
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
|
||||||
.reveal { opacity: 0; animation: rise .55s cubic-bezier(.2, .7, .2, 1) forwards; }
|
|
||||||
|
|
||||||
.signal-bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
|
|
||||||
.signal-bars i { width: 3px; background: var(--accent); border-radius: 1px; animation: bars 1.4s ease-in-out infinite; }
|
|
||||||
.signal-bars i:nth-child(1) { height: 40%; } .signal-bars i:nth-child(2) { height: 70%; animation-delay: .2s; }
|
|
||||||
.signal-bars i:nth-child(3) { height: 100%; animation-delay: .4s; } .signal-bars i:nth-child(4) { height: 55%; animation-delay: .6s; }
|
|
||||||
@keyframes bars { 0%, 100% { transform: scaleY(.5); opacity: .7; } 50% { transform: scaleY(1); opacity: 1; } }
|
|
||||||
|
|
||||||
.theme-toggle { color: var(--text-dim); transition: color .2s ease, background .2s ease; }
|
|
||||||
.theme-toggle:hover { color: var(--text); }
|
|
||||||
html[data-theme="light"] .moon { display: none; }
|
|
||||||
html[data-theme="dark"] .sun, :root:not([data-theme="light"]) .sun { display: none; }
|
|
||||||
|
|
||||||
/* Leaflet polish (dark default; .leaflet-light tweaks tooltip for light) */
|
|
||||||
.leaflet-container { background: var(--bg) !important; }
|
|
||||||
.leaflet-tooltip { background: var(--panel-a); border: 1px solid var(--border-bright); color: var(--text); box-shadow: none; font-family: inherit; font-size: 12px; }
|
|
||||||
.leaflet-tooltip-top::before { border-top-color: var(--border-bright); }
|
|
||||||
.leaflet-control-attribution { background: rgba(0,0,0,0.25) !important; color: var(--text-dim) !important; }
|
|
||||||
.leaflet-control-attribution a { color: var(--text-dim) !important; }
|
|
||||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
|
||||||
::-webkit-scrollbar-thumb { background: rgba(124, 146, 188, 0.22); border-radius: 9px; }
|
|
||||||
</style>
|
|
||||||
{% block head %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body class="min-h-full antialiased">
|
|
||||||
<header class="sticky top-0 z-30 border-b border-[var(--border)] bg-[var(--header-bg)] backdrop-blur-xl">
|
|
||||||
<div class="max-w-5xl mx-auto px-5 py-3.5 flex items-center justify-between">
|
|
||||||
<a href="/portal" class="flex items-center gap-2.5">
|
|
||||||
<span class="signal-bars"><i></i><i></i><i></i><i></i></span>
|
|
||||||
<span class="font-semibold tracking-tight text-[15px]">
|
|
||||||
TMI <span class="text-[var(--text-dim)] font-normal">Monitoring</span>
|
|
||||||
{% if client %}<span class="text-[var(--text-dim)] font-normal mx-0.5">/</span>
|
|
||||||
<span class="text-seismo-orange">{{ client.name }}</span>{% endif %}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<button onclick="togglePortalTheme()" class="theme-toggle p-2 rounded-lg" title="Toggle light / dark" aria-label="Toggle theme">
|
|
||||||
<svg class="moon w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
|
|
||||||
<svg class="sun w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
|
||||||
</button>
|
|
||||||
{% if client %}
|
|
||||||
<a href="/portal/logout" class="text-[13px] text-[var(--text-dim)] hover:text-[var(--text)] transition-colors px-2">Sign out</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="max-w-5xl mx-auto px-5 py-8">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="max-w-5xl mx-auto px-5 py-10 text-[11px] text-[var(--text-dim)] flex items-center gap-2 opacity-70">
|
|
||||||
<span class="w-1 h-1 rounded-full bg-[var(--text-dim)]"></span>
|
|
||||||
Read-only monitoring view · data provided as-is for informational purposes
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Theme toggle. Pages can listen for 'portal-theme' to re-skin canvases/maps.
|
|
||||||
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
|
||||||
// HTML-escape operator-set strings (location/rule names) before innerHTML/tooltip injection.
|
|
||||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
|
||||||
function togglePortalTheme() {
|
|
||||||
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
|
||||||
const next = cur === 'light' ? 'dark' : 'light';
|
|
||||||
document.documentElement.setAttribute('data-theme', next);
|
|
||||||
try { localStorage.setItem('portal-theme', next); } catch (e) {}
|
|
||||||
const mc = document.querySelector('meta[name="theme-color"]');
|
|
||||||
if (mc) mc.setAttribute('content', next === 'light' ? '#eef2f9' : '#080b14');
|
|
||||||
document.dispatchEvent(new CustomEvent('portal-theme', { detail: next }));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% block scripts %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
{% extends "portal/base.html" %}
|
|
||||||
{% block title %}{{ location.name }}{% endblock %}
|
|
||||||
{% block head %}
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
||||||
{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<a href="/portal" class="reveal inline-flex items-center gap-1.5 text-sm text-[var(--text-dim)] hover:text-[var(--text)] transition-colors">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
|
||||||
All locations
|
|
||||||
</a>
|
|
||||||
<div class="reveal mt-3 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<h1 class="text-3xl font-bold tracking-tight">{{ location.name }}</h1>
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<span id="p-badge" class="hidden"></span>
|
|
||||||
<span id="p-fresh" class="text-[var(--text-dim)] font-mono text-xs"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not has_device %}
|
|
||||||
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No device is currently assigned to this location.</div>
|
|
||||||
{% else %}
|
|
||||||
<div id="p-alarm-banner" class="hidden reveal mt-5 px-4 py-3 rounded-xl bg-[rgba(220,38,38,0.10)] border border-[rgba(220,38,38,0.32)] text-[var(--lvl-bad)] text-sm flex items-center gap-2.5">
|
|
||||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/>
|
|
||||||
</svg>
|
|
||||||
<span id="p-alarm-text" class="font-medium">Currently above threshold.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hero console: Leq primary + instrument strip -->
|
|
||||||
<div class="panel reveal mt-5 p-6 sm:p-7" style="animation-delay:60ms">
|
|
||||||
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-1.5">Leq · average</div>
|
|
||||||
<div class="flex items-baseline gap-2.5">
|
|
||||||
<span id="p-leq" class="reading text-6xl sm:text-7xl leading-none font-semibold">--</span>
|
|
||||||
<span class="text-sm text-[var(--text-dim)] font-mono">dB</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hairline mt-6 pt-5 grid grid-cols-2 sm:grid-cols-4 gap-5">
|
|
||||||
<div>
|
|
||||||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lp · instant</div>
|
|
||||||
<div class="mt-1 flex items-baseline gap-1"><span id="p-lp" class="reading text-2xl font-semibold c-lp">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lmax · peak</div>
|
|
||||||
<div class="mt-1 flex items-baseline gap-1"><span id="p-lmax" class="reading text-2xl font-semibold c-lmax">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L1</div>
|
|
||||||
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln1" class="reading text-2xl font-semibold c-l1">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L10</div>
|
|
||||||
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln2" class="reading text-2xl font-semibold c-l10">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Live trace -->
|
|
||||||
<div class="panel reveal mt-5 overflow-hidden" style="animation-delay:120ms">
|
|
||||||
<div class="px-5 pt-4 text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono">Live trace · last 2h</div>
|
|
||||||
<div class="relative px-3 pb-3 pt-2" style="min-height: 340px;">
|
|
||||||
<canvas id="p-chart"></canvas>
|
|
||||||
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-[rgba(8,11,20,0.78)] rounded-xl backdrop-blur-sm">
|
|
||||||
<button onclick="resumeStream()"
|
|
||||||
class="px-4 py-2 rounded-lg bg-seismo-orange/15 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/25 text-sm font-medium transition-colors">
|
|
||||||
⏸ Live paused — tap to resume
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Alert limits (what this location is alerted on) -->
|
|
||||||
<div id="p-limits-section" class="reveal mt-7 hidden" style="animation-delay:180ms">
|
|
||||||
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert limits</div>
|
|
||||||
<div id="p-thresholds" class="space-y-2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Alert history -->
|
|
||||||
<div class="reveal mt-7" style="animation-delay:220ms">
|
|
||||||
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert history</div>
|
|
||||||
<div id="p-events" class="space-y-2"></div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{% if has_device %}
|
|
||||||
<script>
|
|
||||||
const LOC_ID = "{{ location.id }}";
|
|
||||||
const cd = { t: [], lp: [], leq: [], ln1: [], ln2: [] };
|
|
||||||
let chart;
|
|
||||||
const numOrNull = v => { const f = parseFloat(v); return isNaN(f) ? null : f; };
|
|
||||||
|
|
||||||
// Level color for the Leq hero (matches the overview bands).
|
|
||||||
const LEVEL_AMBER = 55, LEVEL_RED = 70;
|
|
||||||
function leqColor(measuring, v) {
|
|
||||||
// CSS var refs so the hero color auto-flips with the theme.
|
|
||||||
if (!measuring || v == null || isNaN(v)) return 'var(--text)';
|
|
||||||
if (v >= LEVEL_RED) return 'var(--lvl-bad)';
|
|
||||||
if (v >= LEVEL_AMBER) return 'var(--lvl-warn)';
|
|
||||||
return 'var(--lvl-ok)';
|
|
||||||
}
|
|
||||||
function paintLeq(measuring, leqVal) {
|
|
||||||
const el = document.getElementById('p-leq');
|
|
||||||
if (el) el.style.color = leqColor(measuring, parseFloat(leqVal));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ds(label) { return { label, data: [], borderWidth: 1.5, pointRadius: 0, tension: 0.35, spanGaps: true }; }
|
|
||||||
function skinChart() {
|
|
||||||
if (!chart) return;
|
|
||||||
const dim = cssVar('--text-dim');
|
|
||||||
const cols = [cssVar('--m-lp'), cssVar('--lvl-ok'), cssVar('--m-l1'), cssVar('--m-l10')];
|
|
||||||
chart.data.datasets.forEach((d, i) => { d.borderColor = cols[i]; d.backgroundColor = cols[i]; });
|
|
||||||
const grid = 'rgba(124,146,188,0.10)', gridX = 'rgba(124,146,188,0.05)', border = 'rgba(124,146,188,0.18)';
|
|
||||||
const y = chart.options.scales.y, x = chart.options.scales.x;
|
|
||||||
y.ticks.color = dim; y.title.color = dim; y.grid.color = grid; y.border.color = border;
|
|
||||||
x.ticks.color = dim; x.grid.color = gridX; x.border.color = border;
|
|
||||||
chart.options.plugins.legend.labels.color = cssVar('--text');
|
|
||||||
chart.update('none');
|
|
||||||
}
|
|
||||||
function initChart() {
|
|
||||||
const ctx = document.getElementById('p-chart').getContext('2d');
|
|
||||||
const mono = { family: 'IBM Plex Mono', size: 10 };
|
|
||||||
chart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: { labels: [], datasets: [ds('Lp'), ds('Leq'), ds('L1'), ds('L10')] },
|
|
||||||
options: {
|
|
||||||
responsive: true, maintainAspectRatio: false, animation: false,
|
|
||||||
interaction: { intersect: false, mode: 'index' },
|
|
||||||
scales: {
|
|
||||||
y: { min: 30, max: 130, title: { display: true, text: 'dB', font: { family: 'IBM Plex Mono' } },
|
|
||||||
ticks: { font: mono }, grid: {}, border: {} },
|
|
||||||
x: { ticks: { font: mono, maxTicksLimit: 8 }, grid: {}, border: {} }
|
|
||||||
},
|
|
||||||
plugins: { legend: { labels: { font: { family: 'Hanken Grotesk' }, usePointStyle: true, pointStyleWidth: 10, boxHeight: 7 } } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
skinChart();
|
|
||||||
}
|
|
||||||
document.addEventListener('portal-theme', skinChart);
|
|
||||||
|
|
||||||
function setCard(id, v) { document.getElementById(id).textContent = (v == null || v === '') ? '--' : v; }
|
|
||||||
function setBadge(measuring, lastSeen) {
|
|
||||||
const b = document.getElementById('p-badge'), f = document.getElementById('p-fresh');
|
|
||||||
const base = 'inline-flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded-full border ';
|
|
||||||
if (measuring === null) { b.className = 'hidden'; b.textContent = ''; }
|
|
||||||
else if (measuring) { b.className = base + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; b.innerHTML = '<span class="live-dot"></span> Live'; }
|
|
||||||
else { b.className = base + 'border-[var(--border)] text-[var(--text-dim)]'; b.textContent = 'Stopped'; }
|
|
||||||
f.innerHTML = fmtFreshness(lastSeen);
|
|
||||||
}
|
|
||||||
function fmtFreshness(iso) {
|
|
||||||
if (!iso) return '<span class="text-[var(--text-dim)]">no recent reading</span>';
|
|
||||||
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
|
||||||
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
|
||||||
let ago, stale = false;
|
|
||||||
if (s < 10) ago = 'just now';
|
|
||||||
else if (s < 60) ago = s + 's ago';
|
|
||||||
else if (s < 3600) { ago = Math.round(s / 60) + 'm ago'; stale = s >= 300; }
|
|
||||||
else { ago = Math.round(s / 3600) + 'h ago'; stale = true; }
|
|
||||||
const cls = stale ? 'text-amber-400' : 'text-[var(--text-dim)]';
|
|
||||||
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${stale ? ' · cached' : ''})</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prefill() {
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/live`)).json();
|
|
||||||
const d = j.data;
|
|
||||||
if (!d) {
|
|
||||||
setBadge(null, null);
|
|
||||||
document.getElementById('p-fresh').textContent =
|
|
||||||
j.reason === 'no_device' ? 'No device assigned' : 'Currently unreachable';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
|
||||||
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
|
||||||
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
|
||||||
setBadge(measuring, d.last_seen);
|
|
||||||
paintLeq(measuring, d.leq);
|
|
||||||
} catch (e) { /* keep last values */ }
|
|
||||||
}
|
|
||||||
async function backfill() {
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/history?hours=2`)).json();
|
|
||||||
for (const row of (j.readings || [])) {
|
|
||||||
cd.t.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
|
||||||
cd.lp.push(numOrNull(row.lp)); cd.leq.push(numOrNull(row.leq));
|
|
||||||
cd.ln1.push(numOrNull(row.ln1)); cd.ln2.push(numOrNull(row.ln2));
|
|
||||||
}
|
|
||||||
chart.data.labels = cd.t;
|
|
||||||
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
|
||||||
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
|
||||||
chart.update('none');
|
|
||||||
} catch (e) { /* leave chart empty */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- live stream (upgrades the cache prefill to a real ~1Hz feed) --------
|
|
||||||
let ws = null, hardCap = null, paused = false;
|
|
||||||
const IDLE_CAP_MS = 15 * 60 * 1000; // auto-close after 15 min so an abandoned
|
|
||||||
// tab doesn't pin the device at 1Hz polling
|
|
||||||
|
|
||||||
function pushPoint(d) {
|
|
||||||
cd.t.push(new Date().toLocaleTimeString());
|
|
||||||
cd.lp.push(numOrNull(d.lp)); cd.leq.push(numOrNull(d.leq));
|
|
||||||
cd.ln1.push(numOrNull(d.ln1)); cd.ln2.push(numOrNull(d.ln2));
|
|
||||||
if (cd.t.length > 600) { cd.t.shift(); cd.lp.shift(); cd.leq.shift(); cd.ln1.shift(); cd.ln2.shift(); }
|
|
||||||
chart.data.labels = cd.t;
|
|
||||||
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
|
||||||
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
|
||||||
chart.update('none');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openStream() {
|
|
||||||
if (paused || ws) return;
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
ws = new WebSocket(`${proto}//${location.host}/portal/api/location/${encodeURIComponent(LOC_ID)}/stream`);
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
|
|
||||||
if (d.feed_status === 'no_device') {
|
|
||||||
setBadge(null, null);
|
|
||||||
document.getElementById('p-fresh').textContent = 'No device assigned';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (d.heartbeat) return;
|
|
||||||
if (d.feed_status === 'unreachable') {
|
|
||||||
document.getElementById('p-fresh').innerHTML = '<span class="text-amber-400">device unreachable</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
|
||||||
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
|
||||||
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
|
||||||
setBadge(measuring, d.timestamp || new Date().toISOString());
|
|
||||||
paintLeq(measuring, d.leq);
|
|
||||||
pushPoint(d);
|
|
||||||
};
|
|
||||||
ws.onclose = () => { ws = null; };
|
|
||||||
ws.onerror = () => {};
|
|
||||||
clearTimeout(hardCap);
|
|
||||||
hardCap = setTimeout(() => { paused = true; closeStream(); showPaused(true); }, IDLE_CAP_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeStream() {
|
|
||||||
clearTimeout(hardCap);
|
|
||||||
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPaused(on) {
|
|
||||||
const el = document.getElementById('p-paused');
|
|
||||||
if (el) el.classList.toggle('hidden', !on);
|
|
||||||
}
|
|
||||||
function resumeStream() {
|
|
||||||
paused = false; showPaused(false);
|
|
||||||
prefill(); // refresh cards instantly on resume
|
|
||||||
openStream();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop streaming when the tab is hidden (client switched away / locked phone) and
|
|
||||||
// resume when it's visible again — the main cost guard, so the device relaxes back
|
|
||||||
// to its idle poll rate the moment nobody is actually looking.
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.hidden) closeStream();
|
|
||||||
else if (!paused) openStream();
|
|
||||||
});
|
|
||||||
window.addEventListener('beforeunload', closeStream);
|
|
||||||
|
|
||||||
// ---- alert history + current-alarm banner (read-only) --------------------
|
|
||||||
const EV_METRIC = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
|
||||||
function fmtAlertTime(iso) { return iso ? new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString() : ''; }
|
|
||||||
|
|
||||||
// ---- alert limits (the active thresholds, read-only) ---------------------
|
|
||||||
function fmtThreshold(r) {
|
|
||||||
const m = EV_METRIC[r.metric] || esc(r.metric);
|
|
||||||
const cmp = r.comparison === 'below' ? 'below' : 'above';
|
|
||||||
let s = `${m} ${cmp} ${r.threshold_db} dB`;
|
|
||||||
if (r.duration_s) s += ` for ${r.duration_s}s`;
|
|
||||||
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}–${r.schedule_end}`;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
async function loadThresholds() {
|
|
||||||
const sec = document.getElementById('p-limits-section');
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/thresholds`)).json();
|
|
||||||
const rules = j.rules || [];
|
|
||||||
if (!rules.length) { sec.classList.add('hidden'); return; }
|
|
||||||
const list = document.getElementById('p-thresholds');
|
|
||||||
list.innerHTML = '';
|
|
||||||
for (const r of rules) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'panel px-3.5 py-2.5 text-sm flex items-center gap-2.5';
|
|
||||||
row.innerHTML = `<span class="w-1.5 h-1.5 rounded-full bg-seismo-orange shrink-0"></span>
|
|
||||||
<span class="text-[var(--text)]">${esc(r.name || 'Alert')}</span>
|
|
||||||
<span class="text-[var(--text-dim)] font-mono text-xs">${fmtThreshold(r)}</span>`;
|
|
||||||
list.appendChild(row);
|
|
||||||
}
|
|
||||||
sec.classList.remove('hidden');
|
|
||||||
} catch (e) { sec.classList.add('hidden'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEvents() {
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json();
|
|
||||||
const events = j.events || [];
|
|
||||||
const banner = document.getElementById('p-alarm-banner');
|
|
||||||
if (j.active) {
|
|
||||||
banner.classList.remove('hidden');
|
|
||||||
document.getElementById('p-alarm-text').textContent =
|
|
||||||
j.active > 1 ? `${j.active} alerts currently active` : 'Currently above threshold.';
|
|
||||||
} else banner.classList.add('hidden');
|
|
||||||
const list = document.getElementById('p-events');
|
|
||||||
if (!events.length) { list.innerHTML = '<div class="text-sm text-[var(--text-dim)]">No alerts have fired.</div>'; return; }
|
|
||||||
list.innerHTML = '';
|
|
||||||
for (const e of events) {
|
|
||||||
const m = EV_METRIC[e.metric] || esc(e.metric);
|
|
||||||
const active = e.status === 'active';
|
|
||||||
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
|
|
||||||
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
|
|
||||||
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'panel px-3.5 py-2.5 text-sm ' + (active ? 'border-[rgba(220,38,38,0.4)]' : '');
|
|
||||||
row.innerHTML = `<div class="${active ? 'text-[var(--lvl-bad)] font-medium' : 'text-[var(--text)]'}">${esc(e.rule_name || 'Alert')} <span class="text-xs text-[var(--text-dim)] font-mono">· ${m} ${e.threshold_db} dB</span></div>
|
|
||||||
<div class="text-xs text-[var(--text-dim)] font-mono mt-0.5">${when}${peak}</div>`;
|
|
||||||
list.appendChild(row);
|
|
||||||
}
|
|
||||||
} catch (e) { /* leave history as-is */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
initChart();
|
|
||||||
prefill(); // instant first paint from cache
|
|
||||||
backfill(); // seed the chart trail
|
|
||||||
openStream(); // then upgrade to the live feed
|
|
||||||
loadEvents();
|
|
||||||
loadThresholds();
|
|
||||||
setInterval(loadEvents, 20000);
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
{% extends "portal/base.html" %}
|
|
||||||
{% block title %}Your locations{% endblock %}
|
|
||||||
{% block head %}
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
||||||
{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="reveal">
|
|
||||||
<div class="text-[11px] uppercase tracking-[0.2em] text-seismo-orange/80 font-mono mb-2">Live monitoring</div>
|
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Your locations</h1>
|
|
||||||
<p class="text-[var(--text-dim)] text-sm mt-1">Real-time sound levels across your active monitoring sites.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if locations %}
|
|
||||||
<!-- Status rollup (filled live from the per-location /live fetches) -->
|
|
||||||
<div id="rollup" class="hidden mt-6 mb-6 flex flex-wrap items-center gap-2.5">
|
|
||||||
<div class="panel px-4 py-2.5 flex items-center gap-2.5">
|
|
||||||
<span class="text-[var(--text-dim)] text-[10px] uppercase tracking-[0.15em]">Locations</span>
|
|
||||||
<b id="r-total" class="reading text-lg font-semibold">–</b>
|
|
||||||
</div>
|
|
||||||
<div class="panel px-4 py-2.5 flex items-center gap-2">
|
|
||||||
<span class="live-dot"></span><b id="r-live" class="reading text-lg font-semibold text-seismo-orange">–</b><span class="text-[var(--text-dim)] text-xs">live</span>
|
|
||||||
</div>
|
|
||||||
<div class="panel px-4 py-2.5 flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-[var(--text-dim)]/50"></span><b id="r-off" class="reading text-lg font-semibold">–</b><span class="text-[var(--text-dim)] text-xs">offline</span>
|
|
||||||
</div>
|
|
||||||
<div id="r-peak-wrap" class="hidden panel px-4 py-2.5 flex items-center gap-2">
|
|
||||||
<span class="text-[var(--text-dim)] text-[10px] uppercase tracking-[0.15em]">Loudest now</span>
|
|
||||||
<b id="r-peak" class="reading text-lg font-semibold text-seismo-orange">–</b><span class="text-[var(--text-dim)] text-xs">dB</span>
|
|
||||||
<span id="r-peak-loc" class="text-[var(--text-dim)] text-sm"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="loc-map" class="panel reveal hidden h-72 overflow-hidden mb-6" style="animation-delay:80ms"></div>
|
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{% for loc in locations %}
|
|
||||||
<a href="/portal/location/{{ loc.id }}" data-loc="{{ loc.id }}"
|
|
||||||
class="loc-tile panel panel-hover reveal block p-5" style="animation-delay: {{ (loop.index0 * 55) + 140 }}ms">
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="font-semibold tracking-tight truncate">{{ loc.name }}</div>
|
|
||||||
<div class="text-xs text-[var(--text-dim)] mt-0.5 truncate">{{ loc.address or loc.project_name or '' }}</div>
|
|
||||||
</div>
|
|
||||||
<span class="loc-badge hidden shrink-0"></span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-5 flex items-baseline gap-1.5">
|
|
||||||
<span class="loc-leq reading text-[2.6rem] leading-none font-semibold">--</span>
|
|
||||||
<span class="text-xs text-[var(--text-dim)] font-mono tracking-wide">dB Leq</span>
|
|
||||||
</div>
|
|
||||||
<div class="loc-fresh text-[11px] text-[var(--text-dim)]/70 mt-2 font-mono"> </div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No active monitoring locations yet.</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<script>
|
|
||||||
const LOCATIONS = {{ locations|tojson }};
|
|
||||||
const liveState = {}; // loc.id -> {status, leq(num|null), leqStr}
|
|
||||||
const markersById = {}; // loc.id -> circleMarker (for live recolor)
|
|
||||||
let tiles = null; // map tile layer (re-skinned on theme toggle)
|
|
||||||
|
|
||||||
// Dot/level color (computed hex; reads the theme CSS vars so it flips with theme).
|
|
||||||
const LEVEL_AMBER = 55, LEVEL_RED = 70;
|
|
||||||
function levelColor(st) {
|
|
||||||
if (!st || st.status !== 'measuring' || st.leq == null) return cssVar('--text-dim');
|
|
||||||
if (st.leq >= LEVEL_RED) return cssVar('--lvl-bad');
|
|
||||||
if (st.leq >= LEVEL_AMBER) return cssVar('--lvl-warn');
|
|
||||||
return cssVar('--lvl-ok');
|
|
||||||
}
|
|
||||||
function tileUrl() {
|
|
||||||
return document.documentElement.getAttribute('data-theme') === 'light'
|
|
||||||
? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
|
||||||
: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
|
||||||
}
|
|
||||||
// Re-skin map tiles + recolor everything when the theme flips.
|
|
||||||
document.addEventListener('portal-theme', () => { if (tiles) tiles.setUrl(tileUrl()); refreshAll(); });
|
|
||||||
function num(v) { const f = parseFloat(v); return isNaN(f) ? null : f; }
|
|
||||||
|
|
||||||
function fmtAgo(iso) {
|
|
||||||
if (!iso) return '';
|
|
||||||
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
|
||||||
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
|
||||||
if (s < 60) return 'updated just now';
|
|
||||||
if (s < 3600) return 'updated ' + Math.round(s / 60) + 'm ago';
|
|
||||||
return 'updated ' + Math.round(s / 3600) + 'h ago';
|
|
||||||
}
|
|
||||||
|
|
||||||
const BADGE_BASE = 'loc-badge inline-flex items-center gap-1.5 shrink-0 px-2.5 py-1 text-[11px] rounded-full border ';
|
|
||||||
|
|
||||||
function updateMarker(loc) {
|
|
||||||
const m = markersById[loc.id]; if (!m) return;
|
|
||||||
const st = liveState[loc.id];
|
|
||||||
m.setStyle({ fillColor: levelColor(st) });
|
|
||||||
let label = `<b>${esc(loc.name)}</b>`;
|
|
||||||
if (st) {
|
|
||||||
if (st.status === 'measuring') label += ` · ${esc(st.leqStr)} dB Leq`;
|
|
||||||
else if (st.status === 'stopped') label += ' · stopped';
|
|
||||||
else if (st.status === 'nodevice') label += ' · no device';
|
|
||||||
else label += ' · offline';
|
|
||||||
}
|
|
||||||
m.setTooltipContent(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTile(loc) {
|
|
||||||
const el = document.querySelector(`.loc-tile[data-loc="${loc.id}"]`);
|
|
||||||
const leqEl = el && el.querySelector('.loc-leq'),
|
|
||||||
badge = el && el.querySelector('.loc-badge'),
|
|
||||||
fresh = el && el.querySelector('.loc-fresh');
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(loc.id)}/live`)).json();
|
|
||||||
const d = j.data;
|
|
||||||
if (!d) {
|
|
||||||
liveState[loc.id] = { status: j.reason === 'no_device' ? 'nodevice' : 'offline', leq: null };
|
|
||||||
if (badge) { badge.classList.remove('hidden'); badge.className = BADGE_BASE + 'border-[var(--border)] text-[var(--text-dim)]'; badge.textContent = j.reason === 'no_device' ? 'No device' : 'Offline'; }
|
|
||||||
if (leqEl) { leqEl.textContent = '--'; leqEl.style.color = 'var(--text-dim)'; }
|
|
||||||
if (fresh) fresh.innerHTML = ' ';
|
|
||||||
} else {
|
|
||||||
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
|
||||||
const leqStr = (d.leq == null || d.leq === '') ? '--' : d.leq;
|
|
||||||
liveState[loc.id] = { status: measuring ? 'measuring' : 'stopped', leq: num(d.leq), leqStr };
|
|
||||||
if (leqEl) { leqEl.textContent = leqStr; leqEl.style.color = measuring ? levelColor(liveState[loc.id]) : 'var(--text)'; }
|
|
||||||
if (badge) {
|
|
||||||
badge.classList.remove('hidden');
|
|
||||||
if (measuring) { badge.className = BADGE_BASE + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; badge.innerHTML = '<span class="live-dot"></span> Live'; }
|
|
||||||
else { badge.className = BADGE_BASE + 'border-[var(--border)] text-[var(--text-dim)]'; badge.textContent = 'Stopped'; }
|
|
||||||
}
|
|
||||||
if (fresh) fresh.textContent = fmtAgo(d.last_seen);
|
|
||||||
}
|
|
||||||
} catch (e) { /* leave placeholders */ }
|
|
||||||
updateMarker(loc);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRollup() {
|
|
||||||
const total = LOCATIONS.length;
|
|
||||||
let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null;
|
|
||||||
for (const l of LOCATIONS) {
|
|
||||||
const s = liveState[l.id]; if (!s) continue;
|
|
||||||
if (s.status === 'measuring') {
|
|
||||||
live++;
|
|
||||||
if (s.leq != null && (peak == null || s.leq > peak)) { peak = s.leq; peakStr = s.leqStr; peakLoc = l.name; }
|
|
||||||
} else if (s.status === 'offline' || s.status === 'nodevice') off++;
|
|
||||||
}
|
|
||||||
document.getElementById('r-total').textContent = total;
|
|
||||||
document.getElementById('r-live').textContent = live;
|
|
||||||
document.getElementById('r-off').textContent = off;
|
|
||||||
const pw = document.getElementById('r-peak-wrap');
|
|
||||||
if (peak != null) {
|
|
||||||
pw.classList.remove('hidden');
|
|
||||||
document.getElementById('r-peak').textContent = peakStr;
|
|
||||||
document.getElementById('r-peak-loc').textContent = peakLoc;
|
|
||||||
} else pw.classList.add('hidden');
|
|
||||||
document.getElementById('rollup').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAll() {
|
|
||||||
await Promise.all(LOCATIONS.map(loadTile));
|
|
||||||
updateRollup();
|
|
||||||
}
|
|
||||||
refreshAll();
|
|
||||||
setInterval(refreshAll, 15000);
|
|
||||||
|
|
||||||
// Map of locations with coordinates — dark tiles, dots recolor live.
|
|
||||||
const withCoords = LOCATIONS.filter(l => l.coordinates);
|
|
||||||
if (withCoords.length) {
|
|
||||||
const mapEl = document.getElementById('loc-map');
|
|
||||||
mapEl.classList.remove('hidden');
|
|
||||||
const map = L.map('loc-map', { scrollWheelZoom: false, attributionControl: true });
|
|
||||||
tiles = L.tileLayer(tileUrl(), {
|
|
||||||
maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap © CARTO'
|
|
||||||
}).addTo(map);
|
|
||||||
const pts = [];
|
|
||||||
withCoords.forEach(l => {
|
|
||||||
const [la, lo] = (l.coordinates || '').split(',').map(Number);
|
|
||||||
if (!isNaN(la) && !isNaN(lo)) {
|
|
||||||
markersById[l.id] = L.circleMarker([la, lo], {
|
|
||||||
radius: 7, fillColor: levelColor(liveState[l.id]), color: '#fff',
|
|
||||||
weight: 2, opacity: 0.9, fillOpacity: 0.95,
|
|
||||||
}).addTo(map).bindTooltip(esc(l.name), { direction: 'top', offset: [0, -6] });
|
|
||||||
pts.push([la, lo]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (pts.length) map.fitBounds(pts, { padding: [36, 36], maxZoom: 15 });
|
|
||||||
else mapEl.classList.add('hidden');
|
|
||||||
LOCATIONS.forEach(updateMarker);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Breadcrumb Navigation -->
|
<!-- Breadcrumb Navigation -->
|
||||||
<div class="mb-6 flex items-center justify-between gap-3">
|
<div class="mb-6">
|
||||||
<nav class="flex items-center space-x-2 text-sm">
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -17,28 +17,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Client portal actions for this project -->
|
|
||||||
<div class="shrink-0 flex items-center gap-2">
|
|
||||||
<button type="button" onclick="openShareModal()"
|
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
|
|
||||||
title="Get a shareable link to this project's client portal">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 11-5.656-5.656l1.5-1.5"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 115.656 5.656l-1.5 1.5"></path>
|
|
||||||
</svg>
|
|
||||||
Copy client link
|
|
||||||
</button>
|
|
||||||
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
|
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
|
|
||||||
title="Preview this project's client portal in a new tab">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
|
||||||
</svg>
|
|
||||||
View client portal
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header (loads dynamically) -->
|
<!-- Header (loads dynamically) -->
|
||||||
@@ -2096,125 +2074,5 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Share client portal link modal -->
|
|
||||||
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
||||||
onclick="if(event.target===this)closeShareModal()">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal link</h3>
|
|
||||||
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
|
||||||
Anyone with a link can view this project's client portal (read-only). Links are revocable.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if portal_open_links %}
|
|
||||||
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) -->
|
|
||||||
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
|
||||||
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input id="open-url" readonly
|
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
|
|
||||||
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div id="share-new" class="hidden mb-4">
|
|
||||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link — copy it now</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input id="share-new-url" readonly
|
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
|
|
||||||
<button onclick="copyShareUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Active links</span>
|
|
||||||
<button onclick="generateShareLink()" class="text-sm text-seismo-orange hover:text-seismo-navy font-medium">+ Generate new link</button>
|
|
||||||
</div>
|
|
||||||
<div id="share-list" class="space-y-2 max-h-56 overflow-y-auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const SHARE_PROJECT_ID = "{{ project_id }}";
|
|
||||||
function openShareModal() {
|
|
||||||
document.getElementById('share-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('share-new').classList.add('hidden');
|
|
||||||
const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on
|
|
||||||
if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
|
|
||||||
loadShareLinks();
|
|
||||||
}
|
|
||||||
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
|
|
||||||
|
|
||||||
function copyOpenUrl(btn) {
|
|
||||||
const inp = document.getElementById('open-url');
|
|
||||||
inp.select();
|
|
||||||
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
|
|
||||||
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
|
|
||||||
else { document.execCommand('copy'); done(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadShareLinks() {
|
|
||||||
const list = document.getElementById('share-list');
|
|
||||||
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-links`)).json();
|
|
||||||
if (!j.links || !j.links.length) {
|
|
||||||
list.innerHTML = '<div class="text-sm text-gray-400">No links yet — generate one above.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = '';
|
|
||||||
for (const l of j.links) {
|
|
||||||
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used';
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
|
|
||||||
row.innerHTML = `<div class="text-sm min-w-0">
|
|
||||||
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div>
|
|
||||||
<div class="text-xs text-gray-400">${last}</div></div>`;
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'shrink-0 text-xs text-red-600 hover:text-red-700';
|
|
||||||
btn.textContent = 'Revoke';
|
|
||||||
btn.onclick = () => revokeShareLink(l.id);
|
|
||||||
row.appendChild(btn);
|
|
||||||
list.appendChild(row);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateShareLink() {
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link`, { method: 'POST' })).json();
|
|
||||||
if (j.url) {
|
|
||||||
document.getElementById('share-new').classList.remove('hidden');
|
|
||||||
document.getElementById('share-new-url').value = j.url;
|
|
||||||
loadShareLinks();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (window.showToast) showToast('Failed to generate link', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyShareUrl(btn) {
|
|
||||||
const inp = document.getElementById('share-new-url');
|
|
||||||
inp.select();
|
|
||||||
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
|
|
||||||
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
|
|
||||||
else { document.execCommand('copy'); done(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeShareLink(id) {
|
|
||||||
if (!confirm('Revoke this link? Anyone using it will be signed out on their next action.')) return;
|
|
||||||
try { await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link/${id}/revoke`, { method: 'POST' }); loadShareLinks(); }
|
|
||||||
catch (e) { if (window.showToast) showToast('Failed to revoke', 'error'); }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -472,20 +472,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>
|
||||||
@@ -904,41 +890,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
|
||||||
|
|||||||
@@ -112,267 +112,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alerts -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mt-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">Alerts
|
|
||||||
<span id="alert-state-badge" class="hidden text-xs px-2 py-0.5 rounded-full"></span>
|
|
||||||
</h2>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Threshold rules evaluated on this device's live feed. An enabled alert keeps the device monitored 24/7.</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="openAlertForm()" type="button"
|
|
||||||
class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">+ Add alert</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="alert-rules-list" class="space-y-2"></div>
|
|
||||||
|
|
||||||
<!-- create / edit form -->
|
|
||||||
<div id="alert-form" class="hidden mt-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900/40">
|
|
||||||
<input type="hidden" id="ar-id">
|
|
||||||
<div class="grid sm:grid-cols-2 gap-3 mb-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Name</label>
|
|
||||||
<input id="ar-name" type="text" placeholder="e.g. Night noise limit"
|
|
||||||
class="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-gray-800 dark:text-gray-200">
|
|
||||||
</div>
|
|
||||||
<label class="flex items-end gap-2 text-sm text-gray-700 dark:text-gray-300 pb-1">
|
|
||||||
<input type="checkbox" id="ar-enabled" checked class="rounded"> Enabled
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<span>Alert when</span>
|
|
||||||
<select id="ar-metric" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
|
||||||
<option value="leq">Leq</option><option value="lp">Lp</option>
|
|
||||||
<option value="lmax">Lmax</option><option value="lpeak">Lpeak</option>
|
|
||||||
<option value="ln1">L1</option><option value="ln2">L10</option>
|
|
||||||
</select>
|
|
||||||
<span>is</span>
|
|
||||||
<select id="ar-comparison" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
|
||||||
<option value="above">above</option><option value="below">below</option>
|
|
||||||
</select>
|
|
||||||
<input id="ar-threshold" type="number" step="0.1" placeholder="65"
|
|
||||||
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>dB</span>
|
|
||||||
<span>for</span>
|
|
||||||
<input id="ar-duration" type="number" min="0" value="0"
|
|
||||||
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>seconds</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input type="checkbox" id="ar-sched-on" onchange="toggleSchedule()" class="rounded"> Only during certain hours
|
|
||||||
</label>
|
|
||||||
<div id="ar-sched" class="hidden mt-2 flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<span>from</span><input id="ar-start" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
|
||||||
<span>to</span><input id="ar-end" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
|
||||||
<span class="ml-2">on</span>
|
|
||||||
<span id="ar-days" class="flex gap-1"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<summary class="cursor-pointer select-none">Advanced</summary>
|
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
|
||||||
<span>Clear margin</span><input id="ar-margin" type="number" step="0.1" value="2"
|
|
||||||
class="w-16 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>dB (hysteresis)</span>
|
|
||||||
<span>Cooldown</span><input id="ar-cooldown" type="number" min="0" value="300"
|
|
||||||
class="w-20 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>s</span>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div class="mt-4 flex gap-2">
|
|
||||||
<button onclick="saveAlertRule()" type="button" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Save</button>
|
|
||||||
<button onclick="closeAlertForm()" type="button" class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Alert history -->
|
|
||||||
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">History</h3>
|
|
||||||
<button onclick="loadAlertEvents()" type="button" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Refresh</button>
|
|
||||||
</div>
|
|
||||||
<div id="alert-events" class="space-y-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const ALERT_UNIT = "{{ unit_id }}";
|
|
||||||
const METRIC_LABELS = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
|
||||||
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Mon=0 .. Sun=6
|
|
||||||
|
|
||||||
// Render the day checkboxes once.
|
|
||||||
(function () {
|
|
||||||
const wrap = document.getElementById('ar-days');
|
|
||||||
DAY_LABELS.forEach((lbl, i) => {
|
|
||||||
const l = document.createElement('label');
|
|
||||||
l.className = 'inline-flex items-center gap-0.5';
|
|
||||||
l.innerHTML = `<input type="checkbox" id="ar-day-${i}" class="rounded"><span class="ml-0.5">${lbl}</span>`;
|
|
||||||
wrap.appendChild(l);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
function condText(r) {
|
|
||||||
const m = METRIC_LABELS[r.metric] || r.metric;
|
|
||||||
let s = `${m} ${r.comparison} ${r.threshold_db} dB`;
|
|
||||||
if (r.duration_s) s += ` for ${r.duration_s}s`;
|
|
||||||
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}–${r.schedule_end}`;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRule(r) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
|
|
||||||
row.innerHTML = `<div class="min-w-0">
|
|
||||||
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${r.name}${r.enabled ? '' : ' <span class="text-xs text-gray-400">(disabled)</span>'}</div>
|
|
||||||
<div class="text-xs text-gray-500">${condText(r)}</div></div>
|
|
||||||
<div class="shrink-0 flex items-center gap-3 text-xs">
|
|
||||||
<button data-act="edit" class="text-seismo-orange hover:underline">Edit</button>
|
|
||||||
<button data-act="del" class="text-red-600 hover:underline">Delete</button>
|
|
||||||
</div>`;
|
|
||||||
row.querySelector('[data-act="edit"]').onclick = () => openAlertForm(r);
|
|
||||||
row.querySelector('[data-act="del"]').onclick = () => deleteAlertRule(r.id);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAlertRules() {
|
|
||||||
const list = document.getElementById('alert-rules-list');
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules`)).json();
|
|
||||||
const rules = j.rules || [];
|
|
||||||
if (!rules.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts configured.</div>'; return; }
|
|
||||||
list.innerHTML = '';
|
|
||||||
rules.forEach(r => list.appendChild(renderRule(r)));
|
|
||||||
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load alerts.</div>'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSchedule() {
|
|
||||||
document.getElementById('ar-sched').classList.toggle('hidden', !document.getElementById('ar-sched-on').checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAlertForm(r) {
|
|
||||||
document.getElementById('alert-form').classList.remove('hidden');
|
|
||||||
document.getElementById('ar-id').value = r ? r.id : '';
|
|
||||||
document.getElementById('ar-name').value = r ? r.name : '';
|
|
||||||
document.getElementById('ar-metric').value = r ? r.metric : 'leq';
|
|
||||||
document.getElementById('ar-comparison').value = r ? r.comparison : 'above';
|
|
||||||
document.getElementById('ar-threshold').value = (r && r.threshold_db != null) ? r.threshold_db : '';
|
|
||||||
document.getElementById('ar-duration').value = r ? r.duration_s : 0;
|
|
||||||
document.getElementById('ar-enabled').checked = r ? r.enabled : true;
|
|
||||||
document.getElementById('ar-margin').value = r ? r.clear_margin_db : 2;
|
|
||||||
document.getElementById('ar-cooldown').value = r ? r.cooldown_s : 300;
|
|
||||||
const hasSched = !!(r && r.schedule_start && r.schedule_end);
|
|
||||||
document.getElementById('ar-sched-on').checked = hasSched;
|
|
||||||
document.getElementById('ar-start').value = hasSched ? r.schedule_start : '';
|
|
||||||
document.getElementById('ar-end').value = hasSched ? r.schedule_end : '';
|
|
||||||
const days = (r && r.schedule_days) ? r.schedule_days.split(',') : [];
|
|
||||||
DAY_LABELS.forEach((_, i) => { document.getElementById('ar-day-' + i).checked = days.includes(String(i)); });
|
|
||||||
toggleSchedule();
|
|
||||||
}
|
|
||||||
function closeAlertForm() { document.getElementById('alert-form').classList.add('hidden'); }
|
|
||||||
|
|
||||||
async function saveAlertRule() {
|
|
||||||
const id = document.getElementById('ar-id').value;
|
|
||||||
const threshold = parseFloat(document.getElementById('ar-threshold').value);
|
|
||||||
if (isNaN(threshold)) { if (window.showToast) showToast('Enter a threshold', 'error'); return; }
|
|
||||||
const schedOn = document.getElementById('ar-sched-on').checked;
|
|
||||||
const days = DAY_LABELS.map((_, i) => document.getElementById('ar-day-' + i).checked ? i : null).filter(v => v !== null);
|
|
||||||
const payload = {
|
|
||||||
name: document.getElementById('ar-name').value || 'Alert',
|
|
||||||
metric: document.getElementById('ar-metric').value,
|
|
||||||
comparison: document.getElementById('ar-comparison').value,
|
|
||||||
threshold_db: threshold,
|
|
||||||
duration_s: parseInt(document.getElementById('ar-duration').value) || 0,
|
|
||||||
clear_margin_db: parseFloat(document.getElementById('ar-margin').value) || 2,
|
|
||||||
cooldown_s: parseInt(document.getElementById('ar-cooldown').value) || 300,
|
|
||||||
schedule_start: schedOn ? (document.getElementById('ar-start').value || null) : null,
|
|
||||||
schedule_end: schedOn ? (document.getElementById('ar-end').value || null) : null,
|
|
||||||
schedule_days: (schedOn && days.length) ? days.join(',') : null,
|
|
||||||
enabled: document.getElementById('ar-enabled').checked,
|
|
||||||
};
|
|
||||||
const url = id ? `/api/slmm/${ALERT_UNIT}/alerts/rules/${id}` : `/api/slmm/${ALERT_UNIT}/alerts/rules`;
|
|
||||||
try {
|
|
||||||
const r = await fetch(url, { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
|
||||||
if (!r.ok) throw new Error('save failed');
|
|
||||||
closeAlertForm(); loadAlertRules();
|
|
||||||
if (window.showToast) showToast('Alert saved', 'success');
|
|
||||||
} catch (e) { if (window.showToast) showToast('Failed to save alert', 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAlertRule(id) {
|
|
||||||
if (!confirm('Delete this alert rule?')) return;
|
|
||||||
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules/${id}`, { method: 'DELETE' }); loadAlertRules(); }
|
|
||||||
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- alert history (events) ----------------------------------------------
|
|
||||||
|
|
||||||
function fmtAlertTime(iso) {
|
|
||||||
if (!iso) return '';
|
|
||||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAlertState(events) {
|
|
||||||
const badge = document.getElementById('alert-state-badge');
|
|
||||||
badge.classList.remove('hidden');
|
|
||||||
const active = events.filter(e => e.status === 'active').length;
|
|
||||||
if (active) {
|
|
||||||
badge.textContent = `● ${active} active`;
|
|
||||||
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
|
||||||
} else {
|
|
||||||
badge.textContent = '✓ All clear';
|
|
||||||
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEvent(e) {
|
|
||||||
const m = METRIC_LABELS[e.metric] || e.metric;
|
|
||||||
const active = e.status === 'active';
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border ' +
|
|
||||||
(active ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
|
|
||||||
: 'border-slate-200 dark:border-slate-700');
|
|
||||||
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
|
|
||||||
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
|
|
||||||
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
|
|
||||||
const ack = e.acknowledged_at ? ` · ack'd${e.acknowledged_by ? ' by ' + e.acknowledged_by : ''}` : '';
|
|
||||||
row.innerHTML = `<div class="min-w-0">
|
|
||||||
<div class="text-sm truncate">
|
|
||||||
<span class="${active ? 'text-red-600 dark:text-red-400 font-medium' : 'text-gray-800 dark:text-gray-200'}">${e.rule_name || 'Alert'}</span>
|
|
||||||
<span class="text-xs text-gray-500"> · ${m} ${e.threshold_db} dB</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500">${when}${peak}${ack}</div></div>`;
|
|
||||||
if (!e.acknowledged_at) {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'shrink-0 text-xs text-seismo-orange hover:underline';
|
|
||||||
btn.textContent = 'Ack';
|
|
||||||
btn.onclick = () => ackEvent(e.id);
|
|
||||||
row.appendChild(btn);
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAlertEvents() {
|
|
||||||
const list = document.getElementById('alert-events');
|
|
||||||
try {
|
|
||||||
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events?limit=50`)).json();
|
|
||||||
const events = j.events || [];
|
|
||||||
updateAlertState(events);
|
|
||||||
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts have fired.</div>'; return; }
|
|
||||||
list.innerHTML = '';
|
|
||||||
events.forEach(e => list.appendChild(renderEvent(e)));
|
|
||||||
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load history.</div>'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ackEvent(id) {
|
|
||||||
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events/${id}/ack`, { method: 'POST' }); loadAlertEvents(); }
|
|
||||||
catch (e) { if (window.showToast) showToast('Failed to acknowledge', 'error'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAlertRules();
|
|
||||||
loadAlertEvents();
|
|
||||||
setInterval(loadAlertEvents, 20000); // surface new breaches / clears
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -51,31 +51,13 @@
|
|||||||
|
|
||||||
<!-- Live Measurement Chart - shows when a device is selected -->
|
<!-- Live Measurement Chart - shows when a device is selected -->
|
||||||
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
<div class="flex items-start justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
Live Measurements
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span id="panel-unit-id" class="text-seismo-orange"></span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</h2>
|
</svg>
|
||||||
<!-- Measuring state + cache freshness (populated from cached /status, no device hit) -->
|
</button>
|
||||||
<div class="mt-1 flex items-center gap-2 text-sm">
|
|
||||||
<span id="panel-measuring-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
|
|
||||||
<span id="panel-freshness" class="text-gray-500 dark:text-gray-400"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button onclick="refreshDashboardPanel()" title="Refresh from device"
|
|
||||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange">
|
|
||||||
<svg id="panel-refresh-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
<!-- Current Metrics -->
|
||||||
@@ -99,14 +81,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||||
<p id="chart-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L1</p>
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
||||||
<p id="chart-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
<p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||||
<p id="chart-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L10</p>
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
||||||
<p id="chart-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
<p id="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,18 +150,9 @@ window.selectedUnitId = null;
|
|||||||
window.dashboardChartData = {
|
window.dashboardChartData = {
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: [],
|
leq: []
|
||||||
ln1: [],
|
|
||||||
ln2: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse a metric to a number, or null (so a missing/"-.-" percentile leaves a gap
|
|
||||||
// in the line instead of dropping it to 0).
|
|
||||||
function numOrNull(v) {
|
|
||||||
const f = parseFloat(v);
|
|
||||||
return isNaN(f) ? null : f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Chart.js
|
// Initialize Chart.js
|
||||||
function initializeDashboardChart() {
|
function initializeDashboardChart() {
|
||||||
if (typeof Chart === 'undefined') {
|
if (typeof Chart === 'undefined') {
|
||||||
@@ -221,26 +194,6 @@ function initializeDashboardChart() {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0
|
pointRadius: 0
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'L1',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(168, 85, 247)',
|
|
||||||
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0,
|
|
||||||
spanGaps: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'L10',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(249, 115, 22)',
|
|
||||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0,
|
|
||||||
spanGaps: true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -291,24 +244,12 @@ function showLiveChart(unitId) {
|
|||||||
initializeDashboardChart();
|
initializeDashboardChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset data for the newly-selected unit (clears any prior unit's line)
|
// Reset data
|
||||||
window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] };
|
window.dashboardChartData = {
|
||||||
if (window.dashboardChart) {
|
timestamps: [],
|
||||||
window.dashboardChart.data.labels = [];
|
lp: [],
|
||||||
window.dashboardChart.data.datasets.forEach(ds => ds.data = []);
|
leq: []
|
||||||
window.dashboardChart.update('none');
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Name the unit; clear stale status until the cache read returns
|
|
||||||
const unitLabel = document.getElementById('panel-unit-id');
|
|
||||||
if (unitLabel) unitLabel.textContent = '· ' + unitId;
|
|
||||||
setPanelStatus(null, null);
|
|
||||||
|
|
||||||
// Populate immediately from CACHE (no device hit): KPI cards + chart trail.
|
|
||||||
prefillDashboardPanel(unitId);
|
|
||||||
backfillDashboardChart(unitId);
|
|
||||||
// Keep the cards updating from cache (~15s) without opening a device stream.
|
|
||||||
startPanelCachePolling(unitId);
|
|
||||||
|
|
||||||
// Scroll to chart
|
// Scroll to chart
|
||||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -316,7 +257,6 @@ function showLiveChart(unitId) {
|
|||||||
|
|
||||||
function closeLiveChart() {
|
function closeLiveChart() {
|
||||||
stopDashboardStream();
|
stopDashboardStream();
|
||||||
stopPanelCachePolling();
|
|
||||||
document.getElementById('live-chart-panel').classList.add('hidden');
|
document.getElementById('live-chart-panel').classList.add('hidden');
|
||||||
window.selectedUnitId = null;
|
window.selectedUnitId = null;
|
||||||
}
|
}
|
||||||
@@ -330,12 +270,17 @@ function startDashboardStream() {
|
|||||||
window.dashboardWebSocket.close();
|
window.dashboardWebSocket.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The live WS takes over from the cache poller; keep the backfilled trail on
|
// Reset chart data
|
||||||
// the chart so the live frames continue the line instead of blanking it.
|
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
||||||
stopPanelCachePolling();
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.data.labels = [];
|
||||||
|
window.dashboardChart.data.datasets[0].data = [];
|
||||||
|
window.dashboardChart.data.datasets[1].data = [];
|
||||||
|
window.dashboardChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
|
||||||
|
|
||||||
window.dashboardWebSocket = new WebSocket(wsUrl);
|
window.dashboardWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -348,10 +293,6 @@ function startDashboardStream() {
|
|||||||
window.dashboardWebSocket.onmessage = function(event) {
|
window.dashboardWebSocket.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
// /monitor sends keepalive 'heartbeat' frames (no metrics) and a per-frame
|
|
||||||
// 'feed_status'; skip heartbeats and offline frames so they don't blank the
|
|
||||||
// metrics or spike the chart with zeros.
|
|
||||||
if (data.heartbeat || data.feed_status === 'unreachable') return;
|
|
||||||
updateDashboardMetrics(data);
|
updateDashboardMetrics(data);
|
||||||
updateDashboardChart(data);
|
updateDashboardChart(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -375,219 +316,37 @@ function stopDashboardStream() {
|
|||||||
window.dashboardWebSocket.close();
|
window.dashboardWebSocket.close();
|
||||||
window.dashboardWebSocket = null;
|
window.dashboardWebSocket = null;
|
||||||
}
|
}
|
||||||
// Fall back to cache polling so the cards keep refreshing while the panel is open.
|
|
||||||
if (window.selectedUnitId && !document.getElementById('live-chart-panel').classList.contains('hidden')) {
|
|
||||||
startPanelCachePolling(window.selectedUnitId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardMetrics(data) {
|
function updateDashboardMetrics(data) {
|
||||||
document.getElementById('chart-lp').textContent = data.lp || '--';
|
document.getElementById('chart-lp').textContent = data.lp || '--';
|
||||||
document.getElementById('chart-leq').textContent = data.leq || '--';
|
document.getElementById('chart-leq').textContent = data.leq || '--';
|
||||||
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
||||||
// Guard: DRD stream frames omit percentiles, so only overwrite when present
|
document.getElementById('chart-lmin').textContent = data.lmin || '--';
|
||||||
// (else the live stream blanks L1/L10 over the cached DOD snapshot values).
|
document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
|
||||||
if (data.ln1 != null) document.getElementById('chart-ln1').textContent = data.ln1;
|
|
||||||
if (data.ln2 != null) document.getElementById('chart-ln2').textContent = data.ln2;
|
|
||||||
if (data.ln1_label) document.getElementById('chart-ln1-label').textContent = data.ln1_label;
|
|
||||||
if (data.ln2_label) document.getElementById('chart-ln2-label').textContent = data.ln2_label;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardChart(data) {
|
function updateDashboardChart(data) {
|
||||||
const cd = window.dashboardChartData;
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
cd.timestamps.push(now.toLocaleTimeString());
|
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
||||||
cd.lp.push(numOrNull(data.lp));
|
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
||||||
cd.leq.push(numOrNull(data.leq));
|
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
||||||
// /monitor (DOD) frames carry ln1/ln2; a DRD frame would omit them -> null gap.
|
|
||||||
cd.ln1.push(numOrNull(data.ln1));
|
|
||||||
cd.ln2.push(numOrNull(data.ln2));
|
|
||||||
|
|
||||||
// Keep a generous window (backfill seeds up to ~120 points from the 2h trail).
|
// Keep only last 60 data points
|
||||||
if (cd.timestamps.length > 600) {
|
if (window.dashboardChartData.timestamps.length > 60) {
|
||||||
cd.timestamps.shift();
|
window.dashboardChartData.timestamps.shift();
|
||||||
cd.lp.shift();
|
window.dashboardChartData.lp.shift();
|
||||||
cd.leq.shift();
|
window.dashboardChartData.leq.shift();
|
||||||
cd.ln1.shift();
|
|
||||||
cd.ln2.shift();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.dashboardChart) {
|
if (window.dashboardChart) {
|
||||||
window.dashboardChart.data.labels = cd.timestamps;
|
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
||||||
window.dashboardChart.data.datasets[0].data = cd.lp;
|
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
||||||
window.dashboardChart.data.datasets[1].data = cd.leq;
|
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
||||||
window.dashboardChart.data.datasets[2].data = cd.ln1;
|
|
||||||
window.dashboardChart.data.datasets[3].data = cd.ln2;
|
|
||||||
window.dashboardChart.update('none');
|
window.dashboardChart.update('none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Cached-data panel population (no device hit) -----------------------
|
|
||||||
|
|
||||||
// Fill the KPI cards + measuring/freshness from the cached NL43Status snapshot.
|
|
||||||
async function prefillDashboardPanel(unitId) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/status`);
|
|
||||||
if (!r.ok) { // 404 = device has never reported yet
|
|
||||||
setPanelStatus(null, null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d = (await r.json()).data || {};
|
|
||||||
updateDashboardMetrics(d); // lp/leq/lmax/ln1/ln2 (ln guards keep cached percentiles)
|
|
||||||
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
|
||||||
setPanelStatus(measuring, d.last_seen);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Panel cache prefill failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed the chart from the downsampled DOD trail so it shows recent trend on open.
|
|
||||||
async function backfillDashboardChart(unitId) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
|
|
||||||
if (!r.ok) return;
|
|
||||||
const readings = (await r.json()).readings || [];
|
|
||||||
const cd = window.dashboardChartData;
|
|
||||||
if (!cd) return;
|
|
||||||
for (const row of readings) {
|
|
||||||
// Trail timestamps are naive UTC; append 'Z' to render in local time
|
|
||||||
// consistently with the live frames (which use local Date.now()).
|
|
||||||
cd.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
|
||||||
cd.lp.push(numOrNull(row.lp));
|
|
||||||
cd.leq.push(numOrNull(row.leq));
|
|
||||||
cd.ln1.push(numOrNull(row.ln1));
|
|
||||||
cd.ln2.push(numOrNull(row.ln2));
|
|
||||||
}
|
|
||||||
if (window.dashboardChart) {
|
|
||||||
window.dashboardChart.data.labels = cd.timestamps;
|
|
||||||
window.dashboardChart.data.datasets[0].data = cd.lp;
|
|
||||||
window.dashboardChart.data.datasets[1].data = cd.leq;
|
|
||||||
window.dashboardChart.data.datasets[2].data = cd.ln1;
|
|
||||||
window.dashboardChart.data.datasets[3].data = cd.ln2;
|
|
||||||
window.dashboardChart.update('none');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Panel chart backfill failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measuring badge + "as of <time> (Xm ago)" freshness, so a cached value is never
|
|
||||||
// mistaken for a live one. measuring: true | false | null(unknown).
|
|
||||||
function setPanelStatus(measuring, lastSeenIso) {
|
|
||||||
const badge = document.getElementById('panel-measuring-badge');
|
|
||||||
const fresh = document.getElementById('panel-freshness');
|
|
||||||
if (badge) {
|
|
||||||
if (measuring === null) {
|
|
||||||
badge.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full';
|
|
||||||
badge.textContent = '';
|
|
||||||
} else if (measuring) {
|
|
||||||
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
|
||||||
badge.textContent = '● Measuring';
|
|
||||||
} else {
|
|
||||||
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
|
||||||
badge.textContent = '■ Stopped';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fresh) fresh.innerHTML = fmtFreshness(lastSeenIso);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Human "x ago" with a staleness hint. Cached timestamps are naive UTC.
|
|
||||||
function fmtFreshness(lastSeenIso) {
|
|
||||||
if (!lastSeenIso) return '<span class="text-gray-400">no cached reading yet</span>';
|
|
||||||
const t = new Date(lastSeenIso.endsWith('Z') ? lastSeenIso : lastSeenIso + 'Z');
|
|
||||||
const secs = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
|
||||||
let ago, stale = false;
|
|
||||||
if (secs < 10) ago = 'just now';
|
|
||||||
else if (secs < 60) ago = secs + 's ago';
|
|
||||||
else if (secs < 3600) { ago = Math.round(secs / 60) + 'm ago'; stale = secs >= 300; }
|
|
||||||
else { ago = Math.round(secs / 3600) + 'h ago'; stale = true; }
|
|
||||||
const cls = stale ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400';
|
|
||||||
const tag = stale ? ' · cached' : '';
|
|
||||||
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${tag})</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache polling: refresh the cards from cache every 15s while the panel is open
|
|
||||||
// and not live-streaming. Pure cache reads — no device contention.
|
|
||||||
function startPanelCachePolling(unitId) {
|
|
||||||
stopPanelCachePolling();
|
|
||||||
window.panelCacheTimer = setInterval(() => {
|
|
||||||
if (window.selectedUnitId) prefillDashboardPanel(window.selectedUnitId);
|
|
||||||
}, 15000);
|
|
||||||
}
|
|
||||||
function stopPanelCachePolling() {
|
|
||||||
if (window.panelCacheTimer) { clearInterval(window.panelCacheTimer); window.panelCacheTimer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- On-demand device refresh (the per-unit + panel refresh buttons) -----
|
|
||||||
|
|
||||||
// One bounded, user-initiated device read: hits the device, updates the cache,
|
|
||||||
// returns the fresh data. Throws on unreachable/disabled.
|
|
||||||
async function forceDeviceRead(unitId) {
|
|
||||||
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/live`);
|
|
||||||
if (!r.ok) {
|
|
||||||
let detail = 'device unreachable';
|
|
||||||
try { detail = (await r.json()).detail || detail; } catch (e) {}
|
|
||||||
throw new Error(detail);
|
|
||||||
}
|
|
||||||
return (await r.json()).data || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function spinIcon(el, on) {
|
|
||||||
if (el) el.classList.toggle('animate-spin', on);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFreshReadToPanel(unitId, d) {
|
|
||||||
if (window.selectedUnitId !== unitId) return;
|
|
||||||
updateDashboardMetrics(d);
|
|
||||||
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
|
||||||
// The read just happened, so "now" is the accurate freshness even if the
|
|
||||||
// /live payload doesn't echo last_seen.
|
|
||||||
setPanelStatus(measuring, d.last_seen || new Date().toISOString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device-list row refresh button.
|
|
||||||
async function refreshSlmUnit(unitId, btn) {
|
|
||||||
const icon = btn ? btn.querySelector('svg') : null;
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
spinIcon(icon, true);
|
|
||||||
try {
|
|
||||||
const d = await forceDeviceRead(unitId);
|
|
||||||
applyFreshReadToPanel(unitId, d);
|
|
||||||
// Reload the list so the row's badge + last-check reflect the new cache.
|
|
||||||
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
|
|
||||||
htmx.trigger('#slm-devices-list', 'load');
|
|
||||||
}
|
|
||||||
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
|
|
||||||
} catch (e) {
|
|
||||||
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
|
|
||||||
else console.warn('refresh failed', e);
|
|
||||||
} finally {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
spinIcon(icon, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Panel header refresh button (refreshes the unit the panel is showing).
|
|
||||||
async function refreshDashboardPanel() {
|
|
||||||
const unitId = window.selectedUnitId;
|
|
||||||
if (!unitId) return;
|
|
||||||
const icon = document.getElementById('panel-refresh-icon');
|
|
||||||
spinIcon(icon, true);
|
|
||||||
try {
|
|
||||||
const d = await forceDeviceRead(unitId);
|
|
||||||
applyFreshReadToPanel(unitId, d);
|
|
||||||
updateDashboardChart(d); // append the fresh point to the chart
|
|
||||||
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
|
|
||||||
htmx.trigger('#slm-devices-list', 'load');
|
|
||||||
}
|
|
||||||
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
|
|
||||||
} catch (e) {
|
|
||||||
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
spinIcon(icon, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration modal - use unified SLM settings modal
|
// Configuration modal - use unified SLM settings modal
|
||||||
function openDeviceConfigModal(unitId) {
|
function openDeviceConfigModal(unitId) {
|
||||||
// Call the unified modal function from slm_settings_modal.html
|
// Call the unified modal function from slm_settings_modal.html
|
||||||
|
|||||||
+35
-47
@@ -129,15 +129,6 @@
|
|||||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
|
|
||||||
<p id="viewLocationContainer" class="mt-1">
|
|
||||||
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
||||||
<span id="viewLocationText">--</span>
|
|
||||||
</a>
|
|
||||||
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
@@ -648,12 +639,18 @@
|
|||||||
{% include "partials/project_picker.html" with context %}
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address / coordinates are managed on the project's
|
<!-- Address -->
|
||||||
MonitoringLocation, not the unit itself. Edit them on
|
<div>
|
||||||
the project page. -->
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
<div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
|
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
|
||||||
Address & coordinates are set on the deployment location.
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
Open the project to edit them.
|
</div>
|
||||||
|
|
||||||
|
<!-- Coordinates -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -851,6 +848,16 @@
|
|||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
||||||
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
@@ -1161,28 +1168,8 @@ function populateViewMode() {
|
|||||||
if (projectLink) projectLink.classList.add('hidden');
|
if (projectLink) projectLink.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deployment Location — comes from the active UnitAssignment →
|
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||||
// MonitoringLocation. Show project link if present, otherwise
|
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||||
// "Not deployed" placeholder.
|
|
||||||
const locLink = document.getElementById('viewLocationLink');
|
|
||||||
const locText = document.getElementById('viewLocationText');
|
|
||||||
const locNoLink = document.getElementById('viewLocationNoLink');
|
|
||||||
const activeLoc = currentUnit.active_location;
|
|
||||||
if (activeLoc && activeLoc.location_id) {
|
|
||||||
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
|
|
||||||
if (locLink) {
|
|
||||||
locLink.href = `/projects/${activeLoc.project_id}`;
|
|
||||||
locLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
if (locNoLink) locNoLink.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
if (locLink) locLink.classList.add('hidden');
|
|
||||||
if (locNoLink) locNoLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address / coordinates also come from the active assignment.
|
|
||||||
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
|
|
||||||
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
|
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||||
@@ -1340,6 +1327,8 @@ function populateEditForm() {
|
|||||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
||||||
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||||
@@ -1620,13 +1609,8 @@ function initUnitMap() {
|
|||||||
// Update marker (can be called multiple times)
|
// Update marker (can be called multiple times)
|
||||||
updateMapMarker(lat, lon);
|
updateMapMarker(lat, lon);
|
||||||
|
|
||||||
// Update location text — prefer the assignment's location name, fall
|
// Update location text
|
||||||
// back to address, then coordinates.
|
|
||||||
const locationParts = [];
|
const locationParts = [];
|
||||||
const loc = currentUnit.active_location;
|
|
||||||
if (loc && loc.name) {
|
|
||||||
locationParts.push(loc.name);
|
|
||||||
}
|
|
||||||
if (currentUnit.address) {
|
if (currentUnit.address) {
|
||||||
locationParts.push(currentUnit.address);
|
locationParts.push(currentUnit.address);
|
||||||
}
|
}
|
||||||
@@ -1740,12 +1724,13 @@ async function uploadPhoto(file) {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// Show success message with metadata info. Location is on the
|
// Show success message with metadata info
|
||||||
// assignment's MonitoringLocation now, so we just surface what GPS
|
|
||||||
// came in — the backend no longer mutates the unit row.
|
|
||||||
let message = 'Photo uploaded successfully!';
|
let message = 'Photo uploaded successfully!';
|
||||||
if (result.metadata && result.metadata.coordinates) {
|
if (result.metadata && result.metadata.coordinates) {
|
||||||
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
message += ' (Unit coordinates updated automatically)';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
message += ' No GPS data found in photo.';
|
message += ' No GPS data found in photo.';
|
||||||
}
|
}
|
||||||
@@ -1753,8 +1738,11 @@ async function uploadPhoto(file) {
|
|||||||
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
statusDiv.textContent = message;
|
statusDiv.textContent = message;
|
||||||
|
|
||||||
// Reload photos
|
// Reload photos and unit data
|
||||||
await loadPhotos();
|
await loadPhotos();
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
await loadUnitData();
|
||||||
|
}
|
||||||
|
|
||||||
// Hide status after 5 seconds
|
// Hide status after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user