Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 684a487203 | |||
| 04cd6b9f24 | |||
| fe7cf91488 | |||
| c1bc391ba2 | |||
| a81764d4bc | |||
| a555cb74dd | |||
| 505c2e3ca5 | |||
| f760e81309 | |||
| 4839d14a22 | |||
| fa7dc39e5e | |||
| 0914cf0a75 | |||
| 29b974a1f7 | |||
| bececafe78 | |||
| 2da9493cb5 | |||
| b908f394ed | |||
| 5455d3a931 | |||
| b971d19068 | |||
| 0103917870 | |||
| 3fc20e104a | |||
| 2031681d0f | |||
| 1cf80ea7ea | |||
| 26b4b1e7e4 | |||
| d3e221b6b1 | |||
| 9f40210057 | |||
| 6c048a9c30 | |||
| 80a8470b55 | |||
| a64c9ced65 | |||
| 2e832708f3 | |||
| 5e3645e229 | |||
| 88f258d1c7 | |||
| 711ef41e5f | |||
| e27aef33ac | |||
| 170dedb138 | |||
| d92d01dc56 | |||
| 17a1a83bdf | |||
| f5e93d5612 | |||
| bdc91177e2 | |||
| 3b818dcd97 | |||
| 61b144efd2 | |||
| c56b7f6c99 | |||
| 08fec696f1 | |||
| 7f561c2c9d |
+100
@@ -5,6 +5,106 @@ 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
|
## [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.
|
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.
|
||||||
|
|||||||
+95
-2
@@ -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
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
|
||||||
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
|
||||||
@@ -66,6 +66,21 @@ 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):
|
||||||
@@ -97,6 +112,10 @@ 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)
|
||||||
|
|
||||||
@@ -390,10 +409,84 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/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,6 +192,7 @@ 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)
|
||||||
@@ -704,3 +705,37 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -91,29 +91,43 @@ 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:
|
||||||
async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
|
# SLMM's /roster carries each unit's CACHED status (last_seen,
|
||||||
|
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device
|
||||||
|
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects
|
||||||
|
# real monitoring without sending Measure? to the device (which the old
|
||||||
|
# /measurement-state did) and competing with DOD polling. One call covers all.
|
||||||
|
slmm_status = {}
|
||||||
try:
|
try:
|
||||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json().get("measurement_state")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
|
|
||||||
if deployed_units:
|
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
|
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster")
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
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 = {}
|
||||||
|
|
||||||
for unit, state in zip(deployed_units, results):
|
# "Recent" = the monitor has a fresh successful read. last_seen only advances
|
||||||
if isinstance(state, Exception):
|
# on a successful poll, so staleness == the device isn't being reached.
|
||||||
unit.measurement_state = None
|
recent_cutoff = datetime.utcnow() - timedelta(minutes=5)
|
||||||
else:
|
for unit in units:
|
||||||
unit.measurement_state = state
|
st = slmm_status.get(unit.id)
|
||||||
|
if not st:
|
||||||
|
continue
|
||||||
|
unit.measurement_state = st.get("measurement_state")
|
||||||
|
last_seen = st.get("last_seen")
|
||||||
|
if last_seen:
|
||||||
|
try:
|
||||||
|
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,
|
||||||
@@ -157,25 +171,18 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
is_measuring = False
|
is_measuring = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor
|
||||||
# Get measurement state
|
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we
|
||||||
state_response = await client.get(
|
# no longer fire Measure? + a fresh DOD read at the device on every command-
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
# center load (which competed with DOD polling for the single connection).
|
||||||
)
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
if state_response.status_code == 200:
|
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||||
state_data = state_response.json()
|
if r.status_code == 200:
|
||||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
current_status = r.json().get("data", {})
|
||||||
is_measuring = state_data.get("is_measuring", False)
|
measurement_state = current_status.get("measurement_state")
|
||||||
|
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 status for {unit_id}: {e}")
|
logger.error(f"Failed to get cached 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,
|
||||||
|
|||||||
@@ -231,6 +231,76 @@ 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):
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
terra-view:
|
web-app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# 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.
|
||||||
@@ -42,6 +42,18 @@
|
|||||||
</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>
|
||||||
@@ -132,7 +144,60 @@ 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();
|
||||||
setInterval(loadSlmmOverview, 30000);
|
loadMonitors();
|
||||||
|
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
{% 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">
|
<div class="absolute top-3 right-3 flex gap-2 z-10">
|
||||||
|
<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">
|
||||||
@@ -20,9 +27,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/slm/{{ unit.id }}" class="block">
|
<a href="/slm/{{ unit.id }}" class="block pr-24">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="min-w-0">
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||||
{% if unit.slm_model %}
|
{% if unit.slm_model %}
|
||||||
@@ -36,25 +42,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badge + last-check on one line (moved off the top-right so it
|
||||||
|
no longer collides with the refresh/chart/gear action icons). -->
|
||||||
|
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
{% if unit.retired %}
|
{% if unit.retired %}
|
||||||
<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>
|
<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 %}
|
{% 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>
|
<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 == "Start" %}
|
{% elif unit.measurement_state in ["Start", "Measure"] %}
|
||||||
<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>
|
<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 %}
|
{% 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>
|
<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="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>
|
<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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.cache_last_seen %}
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
Last check: {{ unit.cache_last_seen|local_datetime }}
|
||||||
{% if unit.slm_last_check %}
|
{% elif unit.slm_last_check %}
|
||||||
Last check: {{ unit.slm_last_check|local_datetime }}
|
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||||
{% else %}
|
{% else %}
|
||||||
No recent check-in
|
No recent check-in
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,6 +143,8 @@
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -173,17 +175,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 class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
<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 id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
<p id="live-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
{% if current_status and current_status.ln1 %}{{ current_status.ln1 }}{% 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 class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
<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 id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
<p id="live-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
{% if current_status and current_status.ln2 %}{{ current_status.ln2 }}{% 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>
|
||||||
@@ -432,6 +434,24 @@ 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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -493,7 +513,37 @@ if (typeof window.currentWebSocket === 'undefined') {
|
|||||||
window.currentWebSocket = null;
|
window.currentWebSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initLiveDataStream(unitId) {
|
// Backfill the chart with the recent DOD trail so it opens with context.
|
||||||
|
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();
|
||||||
@@ -504,17 +554,24 @@ 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[0].data = [];
|
window.liveChart.data.datasets.forEach(ds => ds.data = []);
|
||||||
window.liveChart.data.datasets[1].data = [];
|
|
||||||
window.liveChart.update();
|
window.liveChart.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy
|
// Seed the chart with recent history BEFORE opening the live socket, so live
|
||||||
|
// 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}/live`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`;
|
||||||
|
|
||||||
window.currentWebSocket = new WebSocket(wsUrl);
|
window.currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -530,7 +587,11 @@ 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);
|
||||||
console.log('WebSocket data received:', data);
|
// The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a
|
||||||
|
// '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) {
|
||||||
@@ -559,6 +620,21 @@ 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')) {
|
||||||
@@ -570,11 +646,20 @@ 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 || '--';
|
||||||
}
|
}
|
||||||
if (document.getElementById('live-lmin')) {
|
// Only update Ln values when the frame actually carries them. DRD stream
|
||||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
// frames omit percentiles (DOD-only), so without this guard a live stream
|
||||||
|
// 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 (document.getElementById('live-lpeak')) {
|
if (data.ln1_label && document.getElementById('live-ln1-label')) {
|
||||||
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
document.getElementById('live-ln1-label').textContent = data.ln1_label;
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +668,9 @@ if (typeof window.chartData === 'undefined') {
|
|||||||
window.chartData = {
|
window.chartData = {
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: []
|
leq: [],
|
||||||
|
ln1: [],
|
||||||
|
ln2: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,12 +680,17 @@ 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 only last 60 data points
|
// Keep a rolling window large enough to hold the ~2h backfill (one point/min)
|
||||||
if (window.chartData.timestamps.length > 60) {
|
// plus a good run of live points before the oldest scroll off.
|
||||||
|
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
|
||||||
@@ -606,6 +698,8 @@ 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') {
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
||||||
htmx.trigger('#slm-list', 'load');
|
htmx.trigger('#slm-list', 'load');
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -604,8 +604,10 @@ 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
|
// Refresh any SLM list on the page (only if one is actually present —
|
||||||
if (typeof htmx !== 'undefined') {
|
// the detail/dashboard pages have no #slm-list, and htmx.trigger on a
|
||||||
|
// 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) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
{% 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">
|
<div class="mb-6 flex items-center justify-between gap-3">
|
||||||
<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,6 +17,28 @@
|
|||||||
</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) -->
|
||||||
@@ -2074,5 +2096,125 @@ 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 %}
|
||||||
|
|||||||
@@ -112,4 +112,267 @@
|
|||||||
</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,14 +51,32 @@
|
|||||||
|
|
||||||
<!-- 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-center justify-between mb-6">
|
<div class="flex items-start justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Live Measurements
|
||||||
|
<span id="panel-unit-id" class="text-seismo-orange"></span>
|
||||||
|
</h2>
|
||||||
|
<!-- Measuring state + cache freshness (populated from cached /status, no device hit) -->
|
||||||
|
<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">
|
<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">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
<!-- Current Metrics -->
|
||||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||||
@@ -81,14 +99,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 class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
<p id="chart-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L1</p>
|
||||||
<p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
<p id="chart-ln1" 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 class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
<p id="chart-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L10</p>
|
||||||
<p id="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
<p id="chart-ln2" 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>
|
||||||
@@ -150,9 +168,18 @@ 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') {
|
||||||
@@ -194,6 +221,26 @@ 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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -244,12 +291,24 @@ function showLiveChart(unitId) {
|
|||||||
initializeDashboardChart();
|
initializeDashboardChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset data
|
// Reset data for the newly-selected unit (clears any prior unit's line)
|
||||||
window.dashboardChartData = {
|
window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] };
|
||||||
timestamps: [],
|
if (window.dashboardChart) {
|
||||||
lp: [],
|
window.dashboardChart.data.labels = [];
|
||||||
leq: []
|
window.dashboardChart.data.datasets.forEach(ds => ds.data = []);
|
||||||
};
|
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' });
|
||||||
@@ -257,6 +316,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -270,17 +330,12 @@ function startDashboardStream() {
|
|||||||
window.dashboardWebSocket.close();
|
window.dashboardWebSocket.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset chart data
|
// The live WS takes over from the cache poller; keep the backfilled trail on
|
||||||
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
// the chart so the live frames continue the line instead of blanking it.
|
||||||
if (window.dashboardChart) {
|
stopPanelCachePolling();
|
||||||
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}/live`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`;
|
||||||
|
|
||||||
window.dashboardWebSocket = new WebSocket(wsUrl);
|
window.dashboardWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -293,6 +348,10 @@ 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) {
|
||||||
@@ -316,37 +375,219 @@ 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 || '--';
|
||||||
document.getElementById('chart-lmin').textContent = data.lmin || '--';
|
// Guard: DRD stream frames omit percentiles, so only overwrite when present
|
||||||
document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
|
// (else the live stream blanks L1/L10 over the cached DOD snapshot values).
|
||||||
|
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();
|
||||||
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
cd.timestamps.push(now.toLocaleTimeString());
|
||||||
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
cd.lp.push(numOrNull(data.lp));
|
||||||
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
cd.leq.push(numOrNull(data.leq));
|
||||||
|
// /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 only last 60 data points
|
// Keep a generous window (backfill seeds up to ~120 points from the 2h trail).
|
||||||
if (window.dashboardChartData.timestamps.length > 60) {
|
if (cd.timestamps.length > 600) {
|
||||||
window.dashboardChartData.timestamps.shift();
|
cd.timestamps.shift();
|
||||||
window.dashboardChartData.lp.shift();
|
cd.lp.shift();
|
||||||
window.dashboardChartData.leq.shift();
|
cd.leq.shift();
|
||||||
|
cd.ln1.shift();
|
||||||
|
cd.ln2.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.dashboardChart) {
|
if (window.dashboardChart) {
|
||||||
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
window.dashboardChart.data.labels = cd.timestamps;
|
||||||
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
window.dashboardChart.data.datasets[0].data = cd.lp;
|
||||||
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
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');
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user