Compare commits
65 Commits
main
...
feat/portal-auth
| Author | SHA1 | Date | |
|---|---|---|---|
| 766f64f35f | |||
| da128f6173 | |||
| 20f62a5c0a | |||
| 01180d5725 | |||
| f0a13ea2ff | |||
| 0394f4b0c8 | |||
| eb91441904 | |||
| 25a4a28433 | |||
| b8e4718318 | |||
| c3eb900b7e | |||
| c74dada8b3 | |||
| d75f405857 | |||
| 446d8704f9 | |||
| c04830a0ad | |||
| b11e1a554f | |||
| ad6de946b5 | |||
| d44625374d | |||
| 33069a070d | |||
| ec5d986ac5 | |||
| 0888da32b4 | |||
| 485e3f165b | |||
| 5f02a0bc21 | |||
| 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 | |||
| 182e224f3c | |||
| 2e832708f3 | |||
| 5e3645e229 | |||
| 88f258d1c7 | |||
| 711ef41e5f | |||
| e27aef33ac | |||
| 170dedb138 | |||
| d92d01dc56 | |||
| 17a1a83bdf | |||
| f5e93d5612 | |||
| bdc91177e2 | |||
| 3b818dcd97 | |||
| 61b144efd2 | |||
| c56b7f6c99 | |||
| 08fec696f1 | |||
| 7f561c2c9d |
+108
@@ -5,6 +5,114 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Portal authentication (Phase 1)
|
||||||
|
- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link).
|
||||||
|
- Per-project session isolation (a session for one project can't read another's data); brute-force lockout (5 tries / 15 min) on the password gate.
|
||||||
|
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links and the `portal_admin.py mint-link` command.
|
||||||
|
- **Upgrade:** run `python3 backend/migrate_add_project_portal_auth.py` per DB. Set `COOKIE_SECURE=true` once served over HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [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.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Password hashing for the client portal — argon2id via argon2-cffi.
|
||||||
|
|
||||||
|
Kept separate from portal_auth (cookie signing) so the future operator auth can
|
||||||
|
reuse the same hasher. Never store or log raw passwords."""
|
||||||
|
import secrets
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
|
||||||
|
_ph = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(raw: str) -> str:
|
||||||
|
"""Return an argon2id hash string for a raw password."""
|
||||||
|
return _ph.hash(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(raw: str, hashed: str) -> bool:
|
||||||
|
"""True iff raw matches the stored hash. Never raises."""
|
||||||
|
try:
|
||||||
|
return _ph.verify(hashed, raw)
|
||||||
|
except Exception: # argon2 raises on mismatch/garbage; treat all as "no match"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_password(n_bytes: int = 12) -> str:
|
||||||
|
"""A strong, URL-safe shareable password (~16 chars for n_bytes=12)."""
|
||||||
|
return secrets.token_urlsafe(n_bytes)
|
||||||
+93
-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
|
||||||
|
|
||||||
|
@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,82 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/portal-preview")
|
||||||
|
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Operator testing shortcut: open this project's client portal (no CLI)."""
|
||||||
|
from backend.models import Project
|
||||||
|
from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE
|
||||||
|
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 = mint_portal_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", secure=COOKIE_SECURE)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/{project_id}/portal-access")
|
||||||
|
async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Current portal-access state for the operator panel."""
|
||||||
|
from backend.models import Project
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||||
|
link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \
|
||||||
|
if (p.portal_enabled and p.portal_link_token) else None
|
||||||
|
return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash),
|
||||||
|
"link_url": link_url}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/portal-access/enable")
|
||||||
|
async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Turn the portal on; mint a link token if one doesn't exist yet."""
|
||||||
|
import secrets
|
||||||
|
from backend.models import Project
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||||
|
if not p.portal_link_token:
|
||||||
|
p.portal_link_token = secrets.token_urlsafe(24)
|
||||||
|
p.portal_enabled = True
|
||||||
|
db.commit()
|
||||||
|
link_url = str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}"
|
||||||
|
return {"enabled": True, "has_password": bool(p.portal_password_hash), "link_url": link_url}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/portal-access/password")
|
||||||
|
async def project_portal_access_password(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Generate a fresh strong password, store its hash, return the raw once."""
|
||||||
|
from backend.models import Project
|
||||||
|
from backend.auth_passwords import hash_password, generate_password
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||||
|
raw = generate_password()
|
||||||
|
p.portal_password_hash = hash_password(raw)
|
||||||
|
db.commit()
|
||||||
|
return {"password": raw}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/portal-access/disable")
|
||||||
|
async def project_portal_access_disable(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Turn the portal off and rotate the link token (kills the old link)."""
|
||||||
|
import secrets
|
||||||
|
from backend.models import Project
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||||
|
p.portal_enabled = False
|
||||||
|
p.portal_link_token = secrets.token_urlsafe(24) # rotate so the old link 404s
|
||||||
|
db.commit()
|
||||||
|
return {"enabled": False}
|
||||||
|
|
||||||
|
|
||||||
@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()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Project portal auth (Phase 1).
|
||||||
|
|
||||||
|
Adds the per-project portal gate columns to `projects`:
|
||||||
|
- portal_enabled (BOOLEAN, default 0)
|
||||||
|
- portal_password_hash (TEXT, nullable)
|
||||||
|
- portal_link_token (TEXT, nullable) [+ unique index]
|
||||||
|
|
||||||
|
Idempotent. Run once per existing DB:
|
||||||
|
docker exec terra-view-terra-view-1 python3 backend/migrate_add_project_portal_auth.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_COLUMNS = {
|
||||||
|
"portal_enabled": "BOOLEAN DEFAULT 0",
|
||||||
|
"portal_password_hash": "TEXT",
|
||||||
|
"portal_link_token": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 these columns 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()}
|
||||||
|
|
||||||
|
for col, ddl in _COLUMNS.items():
|
||||||
|
if col in existing:
|
||||||
|
print(f"○ Column already exists: projects.{col}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cursor.execute(f"ALTER TABLE projects ADD COLUMN {col} {ddl}")
|
||||||
|
print(f"✓ Added column: projects.{col} ({ddl})")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"✗ Failed to add projects.{col}: {e}")
|
||||||
|
|
||||||
|
# Unique index on the link token (separate from ADD COLUMN; idempotent via IF NOT EXISTS).
|
||||||
|
try:
|
||||||
|
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_projects_portal_link_token "
|
||||||
|
"ON projects (portal_link_token)")
|
||||||
|
print("✓ Ensured unique index: ix_projects_portal_link_token")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"✗ Failed to create index: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("\n✓ Project portal-auth migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -192,6 +192,11 @@ 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)
|
||||||
|
# --- Client portal (Phase 1: per-project link + password gate) ---
|
||||||
|
portal_enabled = Column(Boolean, default=False) # is the portal open for this project
|
||||||
|
portal_password_hash = Column(String, nullable=True) # argon2 hash of the shared password
|
||||||
|
portal_link_token = Column(String, nullable=True, unique=True, index=True) # unguessable token in the secure link
|
||||||
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 +709,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,161 @@
|
|||||||
|
#!/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-link is RETIRED — per-client magic URLs (/portal/enter) no longer exist.
|
||||||
|
# Client access is now per-PROJECT + password: open the project's page in
|
||||||
|
# Terra-View → "Portal access" to enable it, generate a password, and copy
|
||||||
|
# the /portal/p/<token> link. (create-client / link-project / list / revoke
|
||||||
|
# still operate on the underlying Client/token rows.)
|
||||||
|
|
||||||
|
# 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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
# Retired: the per-client magic URL (/portal/enter/...) was removed when the
|
||||||
|
# portal moved to per-project + password access. Minting a token here would
|
||||||
|
# only produce a dead link.
|
||||||
|
sys.exit(
|
||||||
|
"mint-link is retired: per-client magic URLs (/portal/enter/...) no longer exist.\n"
|
||||||
|
"Client access is now per-project + password. In Terra-View, open the project's page →\n"
|
||||||
|
"'Portal access' to enable the portal, generate a password, and copy the /portal/p/<token>\n"
|
||||||
|
"link to send the client."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,193 @@
|
|||||||
|
"""
|
||||||
|
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 fastapi import Request, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import Client, ClientAccessToken, Project
|
||||||
|
|
||||||
|
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
|
||||||
|
# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
|
||||||
|
# the Synology reverse proxy). Default false so plain-HTTP dev still works.
|
||||||
|
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# --- Phase-1 per-project password gate -------------------------------------------
|
||||||
|
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
|
||||||
|
# owning exactly that project. The project is linked to it via project.client_id so
|
||||||
|
# the existing client-scoped routes (which resolve projects by Project.client_id ==
|
||||||
|
# client.id) surface exactly this one project for the portal session — per-project
|
||||||
|
# isolation with no route changes. (Phase 1 repurposes project.client_id for this; a
|
||||||
|
# real per-client model is the deferred multi-tenant work.)
|
||||||
|
|
||||||
|
|
||||||
|
def portal_client_for_project(project, db) -> Client:
|
||||||
|
"""Get-or-create the dedicated 1:1 portal client for a project, and link the
|
||||||
|
project to it so the client-scoped routes resolve exactly this project."""
|
||||||
|
slug = f"portal-{project.id}"
|
||||||
|
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 "Client"),
|
||||||
|
slug=slug, active=True)
|
||||||
|
db.add(client)
|
||||||
|
db.flush()
|
||||||
|
if project.client_id != client.id:
|
||||||
|
project.client_id = client.id # without this, the client owns no projects
|
||||||
|
db.flush()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def mint_portal_session(project, db) -> str:
|
||||||
|
"""Ensure the project's portal client + an access token exist; return the token
|
||||||
|
id to seal into a session cookie. Reuses an existing token to avoid clutter."""
|
||||||
|
client = portal_client_for_project(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="portal")
|
||||||
|
db.add(tok)
|
||||||
|
db.commit()
|
||||||
|
return tok.id
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_by_link_token(link_token: str, db):
|
||||||
|
"""Return the portal-enabled Project for a link token, or None."""
|
||||||
|
if not link_token:
|
||||||
|
return None
|
||||||
|
return db.query(Project).filter_by(
|
||||||
|
portal_link_token=link_token, portal_enabled=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory brute-force lockout, keyed per link_token (the password is shared per
|
||||||
|
# project, so per-IP granularity buys nothing and an IP term only lets an attacker
|
||||||
|
# reset the budget by rotating source IPs). Resets on restart; adequate for a
|
||||||
|
# read-only surface behind the UniFi edge. Single-worker dev; multi-worker would
|
||||||
|
# need a shared store.
|
||||||
|
MAX_ATTEMPTS = 5
|
||||||
|
LOCK_SECONDS = 15 * 60
|
||||||
|
_failures: dict = {} # key -> (count, first_failure_epoch)
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked(key: str) -> bool:
|
||||||
|
rec = _failures.get(key)
|
||||||
|
if not rec:
|
||||||
|
return False
|
||||||
|
count, first = rec
|
||||||
|
if count < MAX_ATTEMPTS:
|
||||||
|
return False
|
||||||
|
if (time.time() - first) > LOCK_SECONDS:
|
||||||
|
_failures.pop(key, None) # window expired
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def register_failure(key: str) -> None:
|
||||||
|
count, first = _failures.get(key, (0, time.time()))
|
||||||
|
_failures[key] = (count + 1, first)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_failures(key: str) -> None:
|
||||||
|
_failures.pop(key, None)
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""
|
||||||
|
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
||||||
|
|
||||||
|
A client opens a per-project secure link (/portal/p/{link_token}), enters the
|
||||||
|
shared password, and gets a signed session cookie scoped to that project; they
|
||||||
|
then see that project's 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, Form
|
||||||
|
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,
|
||||||
|
COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE,
|
||||||
|
resolve_project_by_link_token, mint_portal_session,
|
||||||
|
is_locked, register_failure, clear_failures,
|
||||||
|
)
|
||||||
|
from backend.auth_passwords import verify_password
|
||||||
|
|
||||||
|
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("/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("/p/{link_token}")
|
||||||
|
def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Secure per-project link: resolve the project from the token, prompt for the
|
||||||
|
shared password. Generic page if the token is unknown/disabled (no leak)."""
|
||||||
|
project = resolve_project_by_link_token(link_token, db)
|
||||||
|
if not project or not project.portal_password_hash:
|
||||||
|
# unknown token, disabled portal, or enabled-but-no-password-set — all look
|
||||||
|
# identical to a client (no existence/config leak, no self-lockout on a
|
||||||
|
# passwordless project).
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
||||||
|
status_code=404)
|
||||||
|
return templates.TemplateResponse("portal/password.html", {
|
||||||
|
"request": request, "link_token": link_token,
|
||||||
|
"project_name": project.name, "error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/p/{link_token}")
|
||||||
|
def portal_password_submit(link_token: str, request: Request,
|
||||||
|
password: str = Form(...), db: Session = Depends(get_db)):
|
||||||
|
"""Verify the shared password; on success mint a project-scoped session cookie."""
|
||||||
|
project = resolve_project_by_link_token(link_token, db)
|
||||||
|
if not project or not project.portal_password_hash:
|
||||||
|
# unknown token, disabled portal, or enabled-but-no-password-set — all look
|
||||||
|
# identical to a client (no existence/config leak, no self-lockout on a
|
||||||
|
# passwordless project).
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
||||||
|
status_code=404)
|
||||||
|
|
||||||
|
# Shared per-project password → lock per token. (Keying on IP too only enabled a
|
||||||
|
# bypass via source-IP rotation, and behind the reverse proxy every client shares
|
||||||
|
# one IP anyway.)
|
||||||
|
lock_key = link_token
|
||||||
|
if is_locked(lock_key):
|
||||||
|
return templates.TemplateResponse("portal/password.html", {
|
||||||
|
"request": request, "link_token": link_token, "project_name": project.name,
|
||||||
|
"error": "Too many attempts. Try again in 15 minutes."}, status_code=200)
|
||||||
|
|
||||||
|
if not verify_password(password, project.portal_password_hash):
|
||||||
|
register_failure(lock_key)
|
||||||
|
return templates.TemplateResponse("portal/password.html", {
|
||||||
|
"request": request, "link_token": link_token, "project_name": project.name,
|
||||||
|
"error": "Incorrect password."}, status_code=200)
|
||||||
|
|
||||||
|
clear_failures(lock_key)
|
||||||
|
token_id = mint_portal_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", secure=COOKIE_SECURE)
|
||||||
|
logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
try:
|
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device
|
||||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
|
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects
|
||||||
if response.status_code == 200:
|
# real monitoring without sending Measure? to the device (which the old
|
||||||
return response.json().get("measurement_state")
|
# /measurement-state did) and competing with DOD polling. One call covers all.
|
||||||
except Exception:
|
slmm_status = {}
|
||||||
return None
|
try:
|
||||||
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,214 @@
|
|||||||
|
# Client Portal — Design & Build Plan
|
||||||
|
|
||||||
|
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
|
||||||
|
|
||||||
|
> **Update (Phase-1 auth landed):** the interim magic-link gate described below is
|
||||||
|
> **retired** — client access is now a per-project secure link + shared password
|
||||||
|
> (argon2). See the design at `docs/superpowers/specs/2026-06-15-portal-auth-design.md`
|
||||||
|
> and the build plan at `docs/superpowers/plans/2026-06-15-portal-auth.md`. The
|
||||||
|
> operator manages access from each project's **Portal access** panel.
|
||||||
|
|
||||||
|
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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
|||||||
|
# Portal Authentication — Design & Build Plan
|
||||||
|
|
||||||
|
**Status:** in development (`feat/portal-auth`) · **Targets:** 0.14.x · **Date:** 2026-06-15
|
||||||
|
|
||||||
|
Supersedes the interim shareable magic-link described in
|
||||||
|
[CLIENT_PORTAL.md](../../CLIENT_PORTAL.md) with a real password gate.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give a client a **secure link + password** that opens a **read-only dashboard** —
|
||||||
|
live data plus access to historical data — for the machines commissioned on
|
||||||
|
**their project**. Nothing else: no device control, no editing, no internal pages.
|
||||||
|
|
||||||
|
This is the first real, internet-facing, client-credentialed surface in the
|
||||||
|
system.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**Phase 1 (this spec — build now):** per-project, password-gated, read-only portal.
|
||||||
|
|
||||||
|
**Deferred (designed, not built — captured below so nothing is lost):**
|
||||||
|
- **Operator auth** — logins + roles for the *internal* app (you / parents).
|
||||||
|
Full design in [Deferred A](#deferred-a--operator-auth-designed-not-built).
|
||||||
|
- **Full multi-tenancy** — per-client rollups, per-project separation within a
|
||||||
|
client, individual client user accounts, and extending the portal to all
|
||||||
|
client-relevant data. [Deferred B](#deferred-b--full-multi-tenancy).
|
||||||
|
|
||||||
|
## Principles (the portal's standing charter)
|
||||||
|
|
||||||
|
1. **Read-only.** A client can look, never touch.
|
||||||
|
2. **Strictly scoped, server-side.** Never trust a project / location / unit id
|
||||||
|
from the request — always re-resolve ownership.
|
||||||
|
3. **Cache-first.** Portal live data comes from SLMM's cache (the same cached
|
||||||
|
reads the internal dashboard uses). A client can never make us hit the device.
|
||||||
|
4. **The gate is a swappable seam.** Everything routes through the scoping layer
|
||||||
|
the portal already has; auth is the thin thing in front of it.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
- **Tenant unit = the project.** Each project is its own portal: one link, one
|
||||||
|
password, showing that project's commissioned machines.
|
||||||
|
- **Shared credential — "company / project-manager wide."** No individual client
|
||||||
|
accounts. Because access is read-only, one shared password per project is an
|
||||||
|
acceptable trade. (Per-person accounts are a Deferred-B item.)
|
||||||
|
- **The link identifies the project; the password authorizes.** A password alone
|
||||||
|
can't say *which* project — so the link carries an unguessable, revocable
|
||||||
|
per-project token, and the password is the shared secret gating it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two layers, two subdomains (hosting target: office Synology NAS behind a UniFi
|
||||||
|
UXG Max; own domain `terra-mechanics.com`).
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
│
|
||||||
|
UniFi UXG Max ── Layer 1 (IT pro): firewall, IPS/IDS, GeoIP allow-list,
|
||||||
|
│ kill-switch rule, 443 only
|
||||||
|
Synology NAS ── DSM reverse proxy + Let's Encrypt wildcard TLS
|
||||||
|
│
|
||||||
|
├─ terra-view.terra-mechanics.com → internal app (operator auth = Deferred A)
|
||||||
|
└─ portal.terra-mechanics.com → LOCKED to /portal/* only, password gate
|
||||||
|
```
|
||||||
|
|
||||||
|
The portal subdomain is **restricted to `/portal/*` at the reverse proxy** — a
|
||||||
|
client on `portal.` physically cannot reach `/roster`, `/admin/*`, etc., even by
|
||||||
|
guessing URLs. This path-lock is a load-bearing control for as long as the
|
||||||
|
internal app remains unauthenticated (until Deferred A lands).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Add three columns to **`Project`**:
|
||||||
|
|
||||||
|
| Column | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `portal_enabled` | bool, default `false` | Is the portal open for this project. |
|
||||||
|
| `portal_password_hash` | text, nullable | argon2id hash of the shared password. Never plaintext. |
|
||||||
|
| `portal_link_token` | text, unique, nullable | Unguessable token in the secure link; identifies the project without exposing its raw id, and is revocable (regenerate → old link dies). |
|
||||||
|
|
||||||
|
**Reused unchanged:** the `Client → Project → MonitoringLocation →
|
||||||
|
UnitAssignment → unit` scoping chain and the existing read-only scoped data
|
||||||
|
routes (`resolve_client_location` + live / history / events).
|
||||||
|
|
||||||
|
**Migration:** `migrate_add_project_portal_auth.py` — an `ALTER TABLE` adding the
|
||||||
|
three columns to the existing (non-empty) `projects` table. Same pattern as
|
||||||
|
`migrate_add_client_portal.py`; `create_all` won't add columns to an existing
|
||||||
|
table.
|
||||||
|
|
||||||
|
## Auth flow
|
||||||
|
|
||||||
|
1. **Operator enables + shares.** On the project page, the operator turns the
|
||||||
|
portal on; the system generates a strong password + a `portal_link_token`; the
|
||||||
|
operator copies **link + password** to send the client.
|
||||||
|
2. **Client opens the link** `portal.terra-mechanics.com/portal/p/{link_token}` →
|
||||||
|
the project is resolved from the token → a **password prompt** renders.
|
||||||
|
3. **Client submits the password** → argon2-verified against
|
||||||
|
`portal_password_hash`. On success, a **signed session cookie scoped to that
|
||||||
|
project** is set (HMAC via the existing `SECRET_KEY` cookie machinery), and
|
||||||
|
they are redirected to the project dashboard.
|
||||||
|
4. **Subsequent requests** re-validate the cookie (signature + project still
|
||||||
|
`portal_enabled` + within cookie max-age) and serve the existing read-only
|
||||||
|
scoped data.
|
||||||
|
5. **Logout** clears the cookie. **Revoke** = disable the portal or regenerate the
|
||||||
|
token / password, which kills outstanding links and any session minted from
|
||||||
|
them on the next request.
|
||||||
|
|
||||||
|
**Lockout:** track failed attempts (per token + IP); after 5 failures refuse for
|
||||||
|
a 15-minute cooldown. Combined with the UniFi GeoIP/IPS edge, that's solid for a
|
||||||
|
read-only surface.
|
||||||
|
|
||||||
|
**Shared cookie machinery:** lift the portal's cookie sign/verify out of
|
||||||
|
`portal_auth.py` into a small shared `backend/auth_cookies.py` — one signer, so
|
||||||
|
the future operator auth (Deferred A) reuses it instead of copy-pasting crypto.
|
||||||
|
|
||||||
|
### Relationship to the existing portal code
|
||||||
|
|
||||||
|
The portal today is *client-scoped* (a `ClientAccessToken` magic-link → a cookie
|
||||||
|
covering all of a client's projects, with a `/portal` overview). Phase 1 makes the
|
||||||
|
entry point *project-scoped*:
|
||||||
|
|
||||||
|
- The **`/portal/p/{link_token}` + password** flow becomes the way in; the
|
||||||
|
interim client magic-link (`/portal/enter/{token}`, `/portal/open/*`,
|
||||||
|
`PORTAL_OPEN_LINKS`) is **retired** in its favor.
|
||||||
|
- The existing read-only views (`/portal/location/{id}`, live / history / events)
|
||||||
|
and the scoping helper are **reused as-is**, just resolved against the project in
|
||||||
|
the session cookie instead of the client.
|
||||||
|
- `Client` / `ClientAccessToken` rows are **left in place** (no destructive
|
||||||
|
migration) — they become the substrate for the Deferred-B per-client rollup.
|
||||||
|
|
||||||
|
## Operator "Portal access" panel
|
||||||
|
|
||||||
|
On the project detail page (internal app), a panel that:
|
||||||
|
- Toggles `portal_enabled`.
|
||||||
|
- **Regenerate password** → shows a freshly generated strong password **once** for
|
||||||
|
the operator to copy.
|
||||||
|
- **Copy link** → the `/portal/p/{token}` URL.
|
||||||
|
- **Revoke** → regenerate the token (old link dies) and/or disable the portal.
|
||||||
|
|
||||||
|
This is an operator action. Until operator auth lands (Deferred A), it sits behind
|
||||||
|
the same posture as the rest of the internal app — see Security notes.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **Bad password** → generic "incorrect password" + increment fail count.
|
||||||
|
- **Unknown / disabled / revoked token** → generic "this portal link is no longer
|
||||||
|
active" page (no project-existence leak).
|
||||||
|
- **Locked out** → "too many attempts, try again in 15 minutes."
|
||||||
|
- **Expired / invalid cookie** → back to the password prompt.
|
||||||
|
- **Portal disabled after a session started** → next request bounced to the prompt.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Implement on `feat/portal-auth` → review → merge to `dev`.
|
||||||
|
2. **Migration** `migrate_add_project_portal_auth.py` on each DB (dev + prod), same
|
||||||
|
drill as the client-portal migration.
|
||||||
|
3. **`SECRET_KEY`** must be a real value in prod (already required for the existing
|
||||||
|
portal cookie; the password gate reuses it).
|
||||||
|
4. **Hosting:** DSM reverse proxy routes `portal.` → app, locked to `/portal/*`;
|
||||||
|
Let's Encrypt wildcard TLS; cookies `Secure` once on TLS. UXG Max GeoIP + IPS +
|
||||||
|
kill-switch handled by the IT pro.
|
||||||
|
5. Enable a real project's portal, set a password, and test the full
|
||||||
|
link → password → dashboard flow over HTTPS before sending a client.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit:** argon2 hash/verify; token resolution (valid / unknown / disabled);
|
||||||
|
lockout counter; cookie sign/verify + scope check; "disabled mid-session" bounce.
|
||||||
|
- **Scoping:** a session for project A cannot read project B's locations / history
|
||||||
|
/ events (404, no existence leak).
|
||||||
|
- **Manual smoke:** enable → copy link + password → open in a fresh browser →
|
||||||
|
wrong password (lockout) → right password → see live + history → logout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred A — Operator auth (designed, not built)
|
||||||
|
|
||||||
|
Logins + roles for the **internal** app (`terra-view.` subdomain). Closes the
|
||||||
|
"internal app is wide open" hole. Full design, ready to lift into its own spec:
|
||||||
|
|
||||||
|
- **Two layers:** UniFi UXG Max edge (IT-pro owned — firewall, IPS, GeoIP,
|
||||||
|
kill-switch, 443-only) + in-app auth (built by us). Internet-exposed with login
|
||||||
|
(no VPN — deliberately, to spare non-technical family members).
|
||||||
|
- **`OperatorUser` model:** `id, email (unique, lowercased), display_name,
|
||||||
|
password_hash (argon2id), role, active, created_at, last_login_at,
|
||||||
|
sessions_valid_from, failed_login_count, locked_until` (+ later `totp_secret`,
|
||||||
|
`totp_enabled`).
|
||||||
|
- **Role ladder:** `superadmin > admin > operator`.
|
||||||
|
- `superadmin` = you — everything + account management (create/disable users,
|
||||||
|
reset passwords, assign roles).
|
||||||
|
- `admin` = your parents (company owners) + you — full run of the app, no
|
||||||
|
operational restrictions.
|
||||||
|
- `operator` = **future** restricted tier for hires; the ladder accepts it with
|
||||||
|
no route changes.
|
||||||
|
- The only thing gated above plain `admin` in v1 is account management
|
||||||
|
(`superadmin`).
|
||||||
|
- **Sessions:** stateless signed cookie reusing `auth_cookies.py` + `SECRET_KEY`
|
||||||
|
(distinct cookie name from the portal). `sessions_valid_from` gives "log out
|
||||||
|
everywhere" / revoke-on-password-change with no session table.
|
||||||
|
- **Authorization:** one **deny-by-default middleware** gates the whole internal
|
||||||
|
app (exempt: `/login`, `/logout`, `/health`, `/static/*`, `/portal/*`);
|
||||||
|
`require_role("admin"|"superadmin")` guards specific routes. New routes are
|
||||||
|
protected automatically.
|
||||||
|
- **Lockout:** 5 fails → 15-min cooldown (doubling).
|
||||||
|
- **2FA:** deferred; TOTP later, admin/superadmin account first.
|
||||||
|
- **Safe rollout (no self-lockout):** ship behind a feature flag
|
||||||
|
`OPERATOR_AUTH_ENABLED` (default **off** = app behaves as today) → seed the first
|
||||||
|
`superadmin` via a small CLI (`backend/operator_admin.py`, modeled on
|
||||||
|
`portal_admin.py`) → log in while still open → flip the flag on → create
|
||||||
|
parents' accounts. Flag back off = instant escape hatch; break-glass =
|
||||||
|
re-run seed / `reset-password` CLI in the container.
|
||||||
|
- **`OperatorUser` is a brand-new table** → `create_all` builds it on startup; only
|
||||||
|
the seed step is required.
|
||||||
|
|
||||||
|
## Deferred B — Full multi-tenancy
|
||||||
|
|
||||||
|
- Per-client **rollup**: one login spanning all of a client's projects.
|
||||||
|
- Per-project **separation within a client** (true tenant isolation).
|
||||||
|
- **Individual client user accounts** (per-person, optional roles) replacing the
|
||||||
|
shared per-project password.
|
||||||
|
- Extend the portal to **all client-relevant data types** (beyond sound:
|
||||||
|
vibration, reports, etc.) — the long-term goal of "everything we can show a
|
||||||
|
client."
|
||||||
|
- All additive on the existing scoping seam — no teardown.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Auth-gated from day one (even the shared password) — never wide-open like the
|
||||||
|
internal app currently is.
|
||||||
|
- Scoping enforced server-side; client-supplied ids always re-checked.
|
||||||
|
- Passwords argon2-hashed; link tokens unguessable + revocable; raw password shown
|
||||||
|
once.
|
||||||
|
- `SECRET_KEY` a real secret in prod; cookies `HttpOnly` + `SameSite=Lax` +
|
||||||
|
`Secure` (once on TLS).
|
||||||
|
- **Known risk:** the operator "Portal access" panel — and the whole internal app —
|
||||||
|
is unauthenticated until Deferred A. Mitigated for now by the `/portal/*`
|
||||||
|
path-lock on the public subdomain plus keeping the internal app off the public
|
||||||
|
internet. Tracked in the hardening backlog (CLIENT_PORTAL.md).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.3
|
||||||
@@ -10,3 +10,4 @@ httpx==0.25.2
|
|||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
rapidfuzz==3.10.1
|
rapidfuzz==3.10.1
|
||||||
schedule==1.2.2
|
schedule==1.2.2
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
|||||||
@@ -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,41 +27,44 @@
|
|||||||
</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 %}
|
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if unit.address %}
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
|
||||||
{% elif unit.location %}
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if unit.address %}
|
||||||
{% if unit.retired %}
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
||||||
<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>
|
{% elif unit.location %}
|
||||||
{% elif not unit.deployed %}
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
|
||||||
{% elif unit.measurement_state == "Start" %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
|
||||||
{% elif unit.is_recent %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<!-- Status badge + last-check on one line (moved off the top-right so it
|
||||||
{% if unit.slm_last_check %}
|
no longer collides with the refresh/chart/gear action icons). -->
|
||||||
Last check: {{ unit.slm_last_check|local_datetime }}
|
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
|
{% if unit.retired %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||||
|
{% elif not unit.deployed %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
||||||
|
{% elif unit.measurement_state in ["Start", "Measure"] %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
||||||
|
{% elif unit.is_recent %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
No recent check-in
|
<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 %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.cache_last_seen %}
|
||||||
|
Last check: {{ unit.cache_last_seen|local_datetime }}
|
||||||
|
{% elif unit.slm_last_check %}
|
||||||
|
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||||
|
{% else %}
|
||||||
|
No recent check-in
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,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 %}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "portal/base.html" %}
|
||||||
|
{% block title %}{{ project_name }}{% 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>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight mb-1">{{ project_name }}</h1>
|
||||||
|
<p class="text-[var(--text-dim)] text-sm mb-6">Enter the password to view this monitoring portal.</p>
|
||||||
|
{% if error %}
|
||||||
|
<p class="text-[var(--lvl-bad)] text-sm mb-4">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/portal/p/{{ link_token }}" class="panel p-5 text-left">
|
||||||
|
<label class="block text-xs text-[var(--text-dim)] mb-1" for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autofocus required
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--panel-b)] text-[var(--text)] mb-4">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full px-4 py-2 rounded-lg bg-seismo-orange text-white font-medium hover:opacity-90">
|
||||||
|
View portal
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% 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 access for this project -->
|
||||||
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
|
<button type="button" onclick="openPortalAccess()"
|
||||||
|
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="Manage this project's client portal access">
|
||||||
|
<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="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>
|
||||||
|
Portal access
|
||||||
|
</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>
|
||||||
|
Preview
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header (loads dynamically) -->
|
<!-- Header (loads dynamically) -->
|
||||||
@@ -2074,5 +2096,97 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Portal access modal -->
|
||||||
|
<div id="portal-access-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onclick="if(event.target===this)closePortalAccess()">
|
||||||
|
<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 access</h3>
|
||||||
|
<button onclick="closePortalAccess()" 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">
|
||||||
|
Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
|
||||||
|
<button id="pa-toggle" onclick="togglePortalEnabled()"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600">…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pa-details" class="hidden space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="pa-link" 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="copyField('pa-link', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Password</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="pa-pass" readonly placeholder="•••••••• (set one below)"
|
||||||
|
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="copyField('pa-pass', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||||
|
</div>
|
||||||
|
<button onclick="regeneratePassword()" class="mt-2 text-sm text-seismo-orange hover:text-seismo-navy font-medium">↻ Generate new password</button>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Shown once — copy it now. Regenerating invalidates the old one.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PA_PROJECT_ID = "{{ project_id }}";
|
||||||
|
let paEnabled = false;
|
||||||
|
function paToast(msg) { if (window.showToast) showToast(msg, 'error'); else alert(msg); }
|
||||||
|
function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
|
||||||
|
function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
function copyField(id, btn) {
|
||||||
|
const inp = document.getElementById(id); 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 loadPortalAccess() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access`);
|
||||||
|
if (!r.ok) throw new Error('load failed');
|
||||||
|
renderPortalAccess(await r.json());
|
||||||
|
} catch (e) { paToast('Could not load portal access.'); }
|
||||||
|
}
|
||||||
|
function renderPortalAccess(j) {
|
||||||
|
paEnabled = !!j.enabled;
|
||||||
|
const toggle = document.getElementById('pa-toggle');
|
||||||
|
const details = document.getElementById('pa-details');
|
||||||
|
toggle.textContent = paEnabled ? 'On — click to disable' : 'Off — click to enable';
|
||||||
|
toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
|
||||||
|
(paEnabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
|
||||||
|
details.classList.toggle('hidden', !paEnabled);
|
||||||
|
document.getElementById('pa-link').value = (paEnabled && j.link_url) ? j.link_url : '';
|
||||||
|
}
|
||||||
|
async function togglePortalEnabled() {
|
||||||
|
const action = paEnabled ? 'disable' : 'enable';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${action}`, { method: 'POST' });
|
||||||
|
if (!r.ok) throw new Error('toggle failed');
|
||||||
|
const j = await r.json();
|
||||||
|
renderPortalAccess(action === 'disable' ? { enabled: false, link_url: null } : j);
|
||||||
|
} catch (e) { paToast(`Could not ${action} the portal.`); }
|
||||||
|
}
|
||||||
|
async function regeneratePassword() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' });
|
||||||
|
if (!r.ok) throw new Error('password failed');
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
|
||||||
|
} catch (e) { paToast('Could not generate a password.'); }
|
||||||
|
}
|
||||||
</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,13 +51,31 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Live Measurements
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
<span id="panel-unit-id" class="text-seismo-orange"></span>
|
||||||
</svg>
|
</h2>
|
||||||
</button>
|
<!-- 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">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
<!-- Current Metrics -->
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Test harness: a throwaway SQLite DB per test, get_db overridden, a TestClient
|
||||||
|
that does NOT run lifespan startup (so schedulers/SLMM polling stay off)."""
|
||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.database import Base, get_db
|
||||||
|
import backend.models as models # noqa: F401 (ensure all tables are registered on Base)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_session(tmp_path):
|
||||||
|
db_file = tmp_path / "test.db"
|
||||||
|
engine = create_engine(f"sqlite:///{db_file}", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
sess = TestingSession()
|
||||||
|
try:
|
||||||
|
yield sess
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(db_session):
|
||||||
|
from backend.main import app # imported lazily so module side effects are contained
|
||||||
|
def _override():
|
||||||
|
yield db_session
|
||||||
|
app.dependency_overrides[get_db] = _override
|
||||||
|
# No `with` → lifespan/startup events do not run (no scheduler/SLMM threads).
|
||||||
|
c = TestClient(app)
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.pop(get_db, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_portal_lockout():
|
||||||
|
"""Portal lockout state is a module-global dict; clear it between tests so
|
||||||
|
one test's failed attempts can't lock out another."""
|
||||||
|
try:
|
||||||
|
import backend.portal_auth as _pa
|
||||||
|
if hasattr(_pa, "_failures"):
|
||||||
|
_pa._failures.clear()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def make_project(db_session, name=None, **kwargs):
|
||||||
|
"""Insert and return a Project with a unique name."""
|
||||||
|
p = models.Project(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=name or f"Proj {uuid.uuid4().hex[:8]}",
|
||||||
|
status="active",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
db_session.add(p)
|
||||||
|
db_session.commit()
|
||||||
|
return p
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from backend.auth_passwords import hash_password, verify_password, generate_password
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_is_not_plaintext_and_verifies():
|
||||||
|
h = hash_password("hunter2")
|
||||||
|
assert h != "hunter2"
|
||||||
|
assert h.startswith("$argon2")
|
||||||
|
assert verify_password("hunter2", h) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_wrong_password():
|
||||||
|
h = hash_password("hunter2")
|
||||||
|
assert verify_password("nope", h) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_is_safe_on_garbage_hash():
|
||||||
|
assert verify_password("anything", "not-a-real-hash") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_password_is_strong_and_unique():
|
||||||
|
a, b = generate_password(), generate_password()
|
||||||
|
assert a != b
|
||||||
|
assert len(a) >= 12
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import importlib
|
||||||
|
from tests.conftest import make_project
|
||||||
|
from backend.auth_passwords import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookie_secure_flag_is_applied(monkeypatch, client, db_session):
|
||||||
|
import backend.portal_auth as pa
|
||||||
|
monkeypatch.setattr(pa, "COOKIE_SECURE", True, raising=False)
|
||||||
|
# also patch the name imported into the router module
|
||||||
|
import backend.routers.portal as pr
|
||||||
|
monkeypatch.setattr(pr, "COOKIE_SECURE", True, raising=False)
|
||||||
|
|
||||||
|
make_project(db_session, portal_enabled=True, portal_link_token="ts",
|
||||||
|
portal_password_hash=hash_password("pw"))
|
||||||
|
r = client.post("/portal/p/ts", data={"password": "pw"}, follow_redirects=False)
|
||||||
|
assert "secure" in r.headers.get("set-cookie", "").lower()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from tests.conftest import make_project
|
||||||
|
from backend.models import Project
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_creates_link_token_and_reports_state(client, db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
r = client.post(f"/projects/{p.id}/portal-access/enable")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["enabled"] is True
|
||||||
|
assert body["link_url"].endswith(f"/portal/p/{db_session.get(Project, p.id).portal_link_token}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_password_returns_raw_once_and_stores_hash(client, db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
client.post(f"/projects/{p.id}/portal-access/enable")
|
||||||
|
r = client.post(f"/projects/{p.id}/portal-access/password")
|
||||||
|
assert r.status_code == 200
|
||||||
|
raw = r.json()["password"]
|
||||||
|
assert len(raw) >= 12
|
||||||
|
fresh = db_session.get(Project, p.id)
|
||||||
|
assert fresh.portal_password_hash and fresh.portal_password_hash != raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_turns_off_and_rotates_token(client, db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
client.post(f"/projects/{p.id}/portal-access/enable")
|
||||||
|
old = db_session.get(Project, p.id).portal_link_token
|
||||||
|
r = client.post(f"/projects/{p.id}/portal-access/disable")
|
||||||
|
assert r.status_code == 200
|
||||||
|
fresh = db_session.get(Project, p.id)
|
||||||
|
assert fresh.portal_enabled is False
|
||||||
|
assert fresh.portal_link_token != old
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_state(client, db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
r = client.get(f"/projects/{p.id}/portal-access")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"enabled": False, "has_password": False, "link_url": None}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import time
|
||||||
|
from tests.conftest import make_project
|
||||||
|
from backend import portal_auth as pa
|
||||||
|
from backend.models import Client, ClientAccessToken
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_client_for_project_is_1to1_and_idempotent(db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
c1 = pa.portal_client_for_project(p, db_session)
|
||||||
|
c2 = pa.portal_client_for_project(p, db_session)
|
||||||
|
assert isinstance(c1, Client) and c1.id == c2.id
|
||||||
|
assert c1.slug == f"portal-{p.id}"
|
||||||
|
assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1
|
||||||
|
# the project must be linked to its portal client, or client-scoped routes find nothing
|
||||||
|
assert p.client_id == c1.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_portal_session_returns_usable_token_id(db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
tid = pa.mint_portal_session(p, db_session)
|
||||||
|
tok = db_session.query(ClientAccessToken).filter_by(id=tid, revoked_at=None).first()
|
||||||
|
assert tok is not None
|
||||||
|
cookie = pa.make_session_cookie(tid)
|
||||||
|
client = pa.client_from_cookie(cookie, db_session)
|
||||||
|
assert client is not None and client.slug == f"portal-{p.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_project_by_link_token(db_session):
|
||||||
|
p = make_project(db_session, portal_enabled=True, portal_link_token="tok-abc")
|
||||||
|
assert pa.resolve_project_by_link_token("tok-abc", db_session).id == p.id
|
||||||
|
assert pa.resolve_project_by_link_token("nope", db_session) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_project_ignores_disabled_portal(db_session):
|
||||||
|
make_project(db_session, portal_enabled=False, portal_link_token="tok-off")
|
||||||
|
assert pa.resolve_project_by_link_token("tok-off", db_session) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_after_max_attempts():
|
||||||
|
pa.clear_failures("k1")
|
||||||
|
assert pa.is_locked("k1") is False
|
||||||
|
for _ in range(pa.MAX_ATTEMPTS):
|
||||||
|
pa.register_failure("k1")
|
||||||
|
assert pa.is_locked("k1") is True
|
||||||
|
pa.clear_failures("k1")
|
||||||
|
assert pa.is_locked("k1") is False
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from tests.conftest import make_project
|
||||||
|
from backend import portal_auth as pa
|
||||||
|
from backend.auth_passwords import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_project(db_session, token="tok-1", password="secretpw"):
|
||||||
|
return make_project(db_session, portal_enabled=True, portal_link_token=token,
|
||||||
|
portal_password_hash=hash_password(password))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_prompt_renders_for_valid_token(client, db_session):
|
||||||
|
_enabled_project(db_session)
|
||||||
|
r = client.get("/portal/p/tok-1")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "password" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unknown_token_shows_generic_page(client, db_session):
|
||||||
|
r = client.get("/portal/p/does-not-exist")
|
||||||
|
assert r.status_code in (403, 404)
|
||||||
|
assert "password" not in r.text.lower() or "isn't valid" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_is_rejected(client, db_session):
|
||||||
|
_enabled_project(db_session, password="rightpw")
|
||||||
|
r = client.post("/portal/p/tok-1", data={"password": "wrongpw"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 200 # re-renders the form, no cookie
|
||||||
|
assert "portal_session" not in r.headers.get("set-cookie", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_correct_password_sets_cookie_and_redirects(client, db_session):
|
||||||
|
_enabled_project(db_session, password="rightpw")
|
||||||
|
r = client.post("/portal/p/tok-1", data={"password": "rightpw"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"] == "/portal"
|
||||||
|
assert "portal_session=" in r.headers.get("set-cookie", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_after_five_wrong(client, db_session):
|
||||||
|
_enabled_project(db_session, token="tok-lock", password="rightpw")
|
||||||
|
for _ in range(5):
|
||||||
|
client.post("/portal/p/tok-lock", data={"password": "x"}, follow_redirects=False)
|
||||||
|
# 6th attempt — even the CORRECT password is refused while locked
|
||||||
|
r = client.post("/portal/p/tok-lock", data={"password": "rightpw"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "portal_session=" not in r.headers.get("set-cookie", "")
|
||||||
|
assert "too many" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_without_password_is_not_accessible(client, db_session):
|
||||||
|
# enabled portal but no password set yet (operator enabled before generating one)
|
||||||
|
# must NOT show a usable form — looks like an invalid link, no self-lockout.
|
||||||
|
make_project(db_session, portal_enabled=True, portal_link_token="tok-nopw")
|
||||||
|
r = client.get("/portal/p/tok-nopw")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert "isn't valid" in r.text.lower()
|
||||||
|
# and a POST can't succeed or set a cookie either
|
||||||
|
r2 = client.post("/portal/p/tok-nopw", data={"password": "anything"}, follow_redirects=False)
|
||||||
|
assert r2.status_code == 404
|
||||||
|
assert "portal_session=" not in r2.headers.get("set-cookie", "")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import sqlite3
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(db_file):
|
||||||
|
conn = sqlite3.connect(db_file)
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||||
|
conn.close()
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_adds_columns_and_is_idempotent(tmp_path, monkeypatch):
|
||||||
|
db_file = tmp_path / "seismo_fleet.db"
|
||||||
|
conn = sqlite3.connect(db_file)
|
||||||
|
conn.execute("CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path) # migration resolves data/ relative to cwd
|
||||||
|
(tmp_path / "data").mkdir()
|
||||||
|
(tmp_path / "data" / "seismo_fleet.db").write_bytes(db_file.read_bytes())
|
||||||
|
|
||||||
|
mod = importlib.import_module("backend.migrate_add_project_portal_auth")
|
||||||
|
mod.migrate()
|
||||||
|
cols = _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||||
|
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= cols
|
||||||
|
|
||||||
|
mod.migrate() # second run must not raise
|
||||||
|
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from starlette.testclient import WebSocketDisconnect
|
||||||
|
from tests.conftest import make_project
|
||||||
|
from backend import portal_auth as pa
|
||||||
|
from backend.auth_passwords import hash_password
|
||||||
|
from backend.models import MonitoringLocation
|
||||||
|
|
||||||
|
|
||||||
|
def _sound_location(db_session, project):
|
||||||
|
loc = MonitoringLocation(
|
||||||
|
id=str(uuid.uuid4()), project_id=project.id, name="Site",
|
||||||
|
location_type="sound", created_at=datetime.utcnow(),
|
||||||
|
sort_order=0)
|
||||||
|
db_session.add(loc)
|
||||||
|
db_session.commit()
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_for_A_cannot_open_B_location(client, db_session):
|
||||||
|
a = make_project(db_session, portal_enabled=True, portal_link_token="ta",
|
||||||
|
portal_password_hash=hash_password("pw"))
|
||||||
|
b = make_project(db_session)
|
||||||
|
b_loc = _sound_location(db_session, b)
|
||||||
|
|
||||||
|
# Establish an A session
|
||||||
|
r = client.post("/portal/p/ta", data={"password": "pw"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
# Try to open B's location page → 404 (not 403), no leak
|
||||||
|
r2 = client.get(f"/portal/location/{b_loc.id}")
|
||||||
|
assert r2.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_can_open_its_own_location(client, db_session):
|
||||||
|
# Positive case: proves the negative test's 404 is real scoping, not a blanket
|
||||||
|
# "client owns nothing" failure — an A session CAN open A's own location.
|
||||||
|
a = make_project(db_session, portal_enabled=True, portal_link_token="ta2",
|
||||||
|
portal_password_hash=hash_password("pw"))
|
||||||
|
a_loc = _sound_location(db_session, a)
|
||||||
|
r = client.post("/portal/p/ta2", data={"password": "pw"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
r2 = client.get(f"/portal/location/{a_loc.id}")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_stream_rejects_unauthenticated(client, db_session):
|
||||||
|
# The live-feed WebSocket must refuse a connection with no session cookie (1008).
|
||||||
|
a = make_project(db_session, portal_enabled=True, portal_link_token="tw1",
|
||||||
|
portal_password_hash=hash_password("pw"))
|
||||||
|
a_loc = _sound_location(db_session, a)
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
|
||||||
|
ws.receive_text()
|
||||||
|
assert exc.value.code == 1008
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_stream_rejects_cross_project(client, db_session, monkeypatch):
|
||||||
|
# The WebSocket enforces the SAME per-project ownership as the HTTP routes: a
|
||||||
|
# B-session opening A's stream is closed 1008 (ownership) before any device feed.
|
||||||
|
# The handler uses SessionLocal() directly (not the get_db override), so point it
|
||||||
|
# at the test DB engine so this genuinely exercises the ownership check (not a
|
||||||
|
# vacuous "client not found").
|
||||||
|
import backend.routers.portal as portal_router
|
||||||
|
monkeypatch.setattr(portal_router, "SessionLocal",
|
||||||
|
sessionmaker(bind=db_session.get_bind()))
|
||||||
|
|
||||||
|
a = make_project(db_session, portal_enabled=True, portal_link_token="tw2",
|
||||||
|
portal_password_hash=hash_password("pw"))
|
||||||
|
a_loc = _sound_location(db_session, a)
|
||||||
|
make_project(db_session, portal_enabled=True, portal_link_token="tw3",
|
||||||
|
portal_password_hash=hash_password("pw"))
|
||||||
|
# Log in as project B, then aim the stream at project A's location.
|
||||||
|
assert client.post("/portal/p/tw3", data={"password": "pw"},
|
||||||
|
follow_redirects=False).status_code == 303
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
|
||||||
|
ws.receive_text()
|
||||||
|
assert exc.value.code == 1008
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from tests.conftest import make_project
|
||||||
|
|
||||||
|
|
||||||
|
def test_enter_and_open_are_gone(client, db_session):
|
||||||
|
assert client.get("/portal/enter/anything", follow_redirects=False).status_code == 404
|
||||||
|
assert client.get("/portal/open/anything", follow_redirects=False).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_link_endpoints_are_gone(client, db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
assert client.post(f"/projects/{p.id}/portal-link").status_code == 404
|
||||||
|
assert client.get(f"/projects/{p.id}/portal-links").status_code == 404
|
||||||
|
assert client.post(f"/projects/{p.id}/portal-link/sometoken/revoke").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_still_mints_a_session(client, db_session):
|
||||||
|
p = make_project(db_session)
|
||||||
|
r = client.get(f"/projects/{p.id}/portal-preview", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert "portal_session=" in r.headers.get("set-cookie", "")
|
||||||
Reference in New Issue
Block a user