5 Commits

Author SHA1 Message Date
serversdown ca3035f50a Merge pull request 'update to 0.13.3' (#57) from dev into main
## [0.13.3] - 2026-06-05

Calibration sync from SFM events.  Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored.  Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit.  Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.

### Added

- **Calibration sync service** (`backend/services/calibration_sync.py`).  Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`.  Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
- **Conflict rule: events-as-truth, manual wins when newer.**  Three outcomes per unit:
  - `already_in_sync` — stored date already matches the event's calibration date.
  - `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved.  Only a future event can supersede it.
  - `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`).  Started in `main.py`'s startup hook, stopped on shutdown.  Does not run on boot — first sync after a server start fires at the next 03:15.
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`.  Powers the Settings button.
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows.  Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab).  Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist.  Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.

### Fixed

- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**.  The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it.  Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.

### Upgrade Notes

No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist.  Rebuild only:

```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```

After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.

---
2026-06-05 02:39:05 -04:00
serversdown 0e2086d6bb Merge pull request 'v0.13.2 - s4 event pipeline complete' (#56) from dev into main
Reviewed-on: #56
2026-06-01 17:41:18 -04:00
serversdown d0685baed5 Merge pull request 'v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes' (#54) from dev into main
Reviewed-on: #54
docs+chore: v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes

CHANGELOG entry for the five commits that landed after the v0.12.0 tag:
two features (Unit Swap wizard at /tools/unit-swap, editable deployment
timeline on /unit/{id}) and two correctness fixes (RosterUnit.deployed
now flips on swap/unassign/promote; deployment timeline now respects
user timezone for both display and edits).  No schema migrations.

README bumped to v0.12.1 with new bullets for the post-v0.12.0 features
and several already-shipped items that were missing from the list (SFM
Event DB Manager, Deployment-History calendar + Gantt tabs, reusable
location-map partial).  backend/main.py VERSION constant bumped too.
2026-05-20 11:44:47 -04:00
serversdown 275a168046 Merge pull request 'merge v0.12.0' (#51) from dev into main
Reviewed-on: #51
2026-05-17 19:44:56 -04:00
serversdown f4fd1c943d Merge pull request 'v0.11.0' (#50) from release/0.11.0 into main
## [0.11.0] - 2026-05-15

Operator-facing polish release.  All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.

### Added
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events.  Cascade-closes active unit assignments and cancels pending scheduled actions at the location.  Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming).  Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page.  Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups.  Click a bar to scroll the matching list row into view with a flash highlight.
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record).  Now auto-detected and surfaceable in the timeline header — one click combines them into a single record.  Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment.  Trash icon in each row of the location's Deployment History panel.  Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history.  Writes an `assignment_deleted` UnitHistory row.
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left.  Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`.  Implementation uses native HTML5 drag-and-drop (no library).  New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu.  Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline).  Sound locations still show session counts.
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map.  One pin per active monitoring location (parsed from the `coordinates` field).  Click pin → scrolls + flashes the matching card.  Tooltip on hover.  Locations without coordinates surface as an inline hint below the map.  If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.

### Changed
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores.  Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio.  Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens.  Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90.  Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).

### Fixed
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler.  Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
    - **Location Remove button** on the project page
    - **Metadata-backfill typeahead dropdown** (existing project + location pickers)
    - **Project-merge typeahead dropdown** (in the per-project header)
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`.  Fixed by adding `isolation: isolate` to the map container.
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`.  Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.

### Migration Notes
Run on each database before deploying.  Both migrations are idempotent and non-destructive.

```bash
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
```

Or sweep all migrations at once (safe — already-applied ones no-op):

```bash
for f in backend/migrate_*.py; do
  docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```

New columns added this release:
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
- `monitoring_locations.removal_reason` (TEXT, nullable)
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration

**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns.  Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.

---
2026-05-15 19:16:42 -04:00
39 changed files with 133 additions and 4969 deletions
-108
View File
@@ -5,114 +5,6 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
SLM live monitoring — fan-out feed + cache-first reads. Targets **0.14.0**. The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed. This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand. Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.
### Added
- **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
- **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0.
- **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
- **Live Measurements panel auto-populates from cache.** Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit. Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale). Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
- **Refresh buttons** — one per device-list row, one in the panel header. On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.
### Changed
- **Dashboard device list + command center read SLMM's cache, not the device.** `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load. This stops dashboard loads from stealing the device's single connection from the live monitor. The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
- **Device-list freshness reflects real monitoring.** The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates. The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.
### Fixed
- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.** `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`. The deploy POST had already succeeded, so the operator saw both the green success **and** a red error. Both call sites now guard on the element existing (`slm_settings_modal.html`).
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.
### Upgrade Notes
Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.
```bash
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py
# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
```
The two builds must ship **together**. Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.
---
### Client portal *(new — read-only client-facing view)*
A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
locations, live. Built inside Terra-View (no new service), reusing the cached
SLMM feed; every route resolves the client through one swappable
`get_current_client` gate, so the interim magic/open-link auth can be replaced
(M4) without touching routes or templates. Strictly read-only — no device control.
#### Added
- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a
`Project.client_id` FK. A signed (HMAC) session cookie carries the access-token
id, re-validated against the DB each request (revoke kills live sessions, with
server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a
dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream
**auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
- **Locations overview.** Live status map (level-colored dots, dark/light CARTO
tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the
headline metric.
- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail
page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
in the portal; enabling a rule pins that device's monitor on so alerts evaluate
round-the-clock.
- **Operator sharing tools.** A **"View client portal"** preview button and a
**"Copy client link"** modal (mint / list / revoke magic links) on the project
page, plus a `backend/portal_admin.py` CLI.
- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI +
IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
**light/dark toggle** (light default, persisted, no-flash).
#### Security
- All scoping enforced server-side (404-not-403, no existence leak); client
endpoints return **scrubbed** projections (no device-health/internal ids); WS
frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
Pre-merge code review hardened cookie expiry, open-links default, and the slug
collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".
#### Upgrade Notes
- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
(adds `projects.client_id`; the `clients` / `client_access_tokens` tables
auto-create).
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
and keep **`PORTAL_OPEN_LINKS=false`** there.
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
cooldown + keepalive coupling) — same build pairing as above.
---
### 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.
-26
View File
@@ -1,26 +0,0 @@
"""Password hashing for the client portal — argon2id via argon2-cffi.
Kept separate from portal_auth (cookie signing) so the future operator auth can
reuse the same hasher. Never store or log raw passwords."""
import secrets
from argon2 import PasswordHasher
_ph = PasswordHasher()
def hash_password(raw: str) -> str:
"""Return an argon2id hash string for a raw password."""
return _ph.hash(raw)
def verify_password(raw: str, hashed: str) -> bool:
"""True iff raw matches the stored hash. Never raises."""
try:
return _ph.verify(hashed, raw)
except Exception: # argon2 raises on mismatch/garbage; treat all as "no match"
return False
def generate_password(n_bytes: int = 12) -> str:
"""A strong, URL-safe shareable password (~16 chars for n_bytes=12)."""
return secrets.token_urlsafe(n_bytes)
+2 -93
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -66,21 +66,6 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Use shared templates configuration with timezone filters # Use shared templates configuration with timezone filters
from backend.templates_config import templates from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client).
from backend.portal_auth import PortalAuthError
@app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError):
if request.url.path.startswith("/portal/api"):
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "required"},
status_code=401,
)
# Add custom context processor to inject environment variable into all templates # Add custom context processor to inject environment variable into all templates
@app.middleware("http") @app.middleware("http")
async def add_environment_to_context(request: Request, call_next): async def add_environment_to_context(request: Request, call_next):
@@ -112,10 +97,6 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router) app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router) app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router) app.include_router(seismo_dashboard.router)
# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md
from backend.routers import portal
app.include_router(portal.router)
app.include_router(sfm.router) app.include_router(sfm.router)
app.include_router(modem_dashboard.router) app.include_router(modem_dashboard.router)
@@ -409,82 +390,10 @@ async def project_detail_page(request: Request, project_id: str):
"""Project detail dashboard""" """Project detail dashboard"""
return templates.TemplateResponse("projects/detail.html", { return templates.TemplateResponse("projects/detail.html", {
"request": request, "request": request,
"project_id": project_id, "project_id": project_id
}) })
@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,
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""
Database migration: Client Portal (M1).
Adds the authoritative client link to projects:
- projects.client_id (TEXT, nullable) -> clients.id
The `clients` and `client_access_tokens` tables are created automatically by
SQLAlchemy `create_all` at app startup (they're brand-new tables), so this
migration only handles the column that create_all won't add to an existing
`projects` table.
Run once per database:
docker exec terra-view-terra-view-1 python3 backend/migrate_add_client_portal.py
"""
import sqlite3
from pathlib import Path
def migrate():
possible_paths = [
Path("data/seismo_fleet.db"),
Path("data/sfm.db"),
Path("data/seismo.db"),
]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("A fresh DB created via models.py will include projects.client_id automatically.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(projects)")
existing = {row[1] for row in cursor.fetchall()}
if "client_id" not in existing:
try:
cursor.execute("ALTER TABLE projects ADD COLUMN client_id TEXT")
print("✓ Added column: projects.client_id (TEXT)")
except sqlite3.OperationalError as e:
print(f"✗ Failed to add projects.client_id: {e}")
else:
print("○ Column already exists: projects.client_id")
conn.commit()
conn.close()
print("\n✓ Client-portal migration complete.")
print(" Note: `clients` + `client_access_tokens` tables auto-create on app startup.")
if __name__ == "__main__":
migrate()
@@ -1,61 +0,0 @@
#!/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()
-39
View File
@@ -192,11 +192,6 @@ class Project(Base):
# Project metadata # Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
# --- 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)
@@ -709,37 +704,3 @@ class PendingDeployment(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================================
# CLIENT PORTAL — read-only, scoped client access (see docs/CLIENT_PORTAL.md)
# ============================================================================
class Client(Base):
"""A portal client (customer org). Owns one or more Projects via
Project.client_id; their portal surfaces only those projects' locations.
Read-only — clients never control devices."""
__tablename__ = "clients"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False) # display name, e.g. "PJ Dick"
slug = Column(String, nullable=False, unique=True, index=True) # URL-safe handle
contact_email = Column(String, nullable=True) # for M4 magic-link
active = Column(Boolean, default=True) # False = portal access off
created_at = Column(DateTime, default=datetime.utcnow)
class ClientAccessToken(Base):
"""Interim 'magic URL' gate (M1-M3). The raw secret lives in the link and is
shown once on creation; only its sha256 is stored here. Revoke by setting
revoked_at. In M4 this is replaced behind get_current_client() without
touching routes/templates."""
__tablename__ = "client_access_tokens"
id = Column(String, primary_key=True, index=True) # UUID
client_id = Column(String, nullable=False, index=True) # FK -> clients.id
token_hash = Column(String, nullable=False, index=True) # sha256 hex of the secret
label = Column(String, nullable=True) # e.g. "Dave's link"
created_at = Column(DateTime, default=datetime.utcnow)
last_used_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True) # set = link no longer works
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""
Client-portal admin CLI (M1). Operator tooling — run inside the terra-view
container against the live DB. The raw magic-link token is shown ONCE on mint;
only its hash is stored.
# create a client
python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com]
# attach a project to a client (sets Project.client_id) — by id, number, or name
python3 backend/portal_admin.py link-project --slug myler --project-id <PID>
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
# mint-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()
-193
View File
@@ -1,193 +0,0 @@
"""
Client-portal auth the swappable gate (see docs/CLIENT_PORTAL.md).
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
mints a signed session cookie. Every portal route depends on get_current_client();
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
against the DB on every request, so revoking a link (revoked_at) kills its live
sessions on the next request not just future clicks.
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
"""
import os
import hmac
import json
import time
import uuid
import base64
import hashlib
import logging
import secrets
from 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)
-375
View File
@@ -1,375 +0,0 @@
"""
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
+37 -44
View File
@@ -91,43 +91,29 @@ async def get_slm_units(
one_hour_ago = datetime.utcnow() - timedelta(hours=1) one_hour_ago = datetime.utcnow() - timedelta(hours=1)
for unit in units: for unit in units:
# Legacy default from the roster field; refined from SLMM's cached status below.
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago) unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
unit.measurement_state = None
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
if include_measurement: if include_measurement:
# SLMM's /roster carries each unit's CACHED status (last_seen, async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device try:
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
# real monitoring without sending Measure? to the device (which the old if response.status_code == 200:
# /measurement-state did) and competing with DOD polling. One call covers all. return response.json().get("measurement_state")
slmm_status = {} except Exception:
try: return None
async with httpx.AsyncClient(timeout=3.0) as client: return None
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster")
if r.status_code == 200:
for dev in (r.json().get("devices") or []):
slmm_status[dev.get("unit_id")] = dev.get("status") or {}
except Exception:
slmm_status = {}
# "Recent" = the monitor has a fresh successful read. last_seen only advances deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
# on a successful poll, so staleness == the device isn't being reached. if deployed_units:
recent_cutoff = datetime.utcnow() - timedelta(minutes=5) async with httpx.AsyncClient(timeout=3.0) as client:
for unit in units: tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
st = slmm_status.get(unit.id) results = await asyncio.gather(*tasks, return_exceptions=True)
if not st:
continue for unit, state in zip(deployed_units, results):
unit.measurement_state = st.get("measurement_state") if isinstance(state, Exception):
last_seen = st.get("last_seen") unit.measurement_state = None
if last_seen: else:
try: unit.measurement_state = state
ls = datetime.fromisoformat(last_seen.replace("Z", ""))
unit.is_recent = ls > recent_cutoff
unit.cache_last_seen = ls # the real freshness the monitor updates
except Exception:
pass
return templates.TemplateResponse("partials/slm_device_list.html", { return templates.TemplateResponse("partials/slm_device_list.html", {
"request": request, "request": request,
@@ -171,18 +157,25 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
is_measuring = False is_measuring = False
try: try:
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor async with httpx.AsyncClient(timeout=10.0) as client:
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we # Get measurement state
# no longer fire Measure? + a fresh DOD read at the device on every command- state_response = await client.get(
# center load (which competed with DOD polling for the single connection). f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
async with httpx.AsyncClient(timeout=5.0) as client: )
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status") if state_response.status_code == 200:
if r.status_code == 200: state_data = state_response.json()
current_status = r.json().get("data", {}) measurement_state = state_data.get("measurement_state", "Unknown")
measurement_state = current_status.get("measurement_state") is_measuring = state_data.get("is_measuring", False)
is_measuring = measurement_state in ("Start", "Measure")
# Get live status (measurement_start_time is already stored in SLMM database)
status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
)
if status_response.status_code == 200:
status_data = status_response.json()
current_status = status_data.get("data", {})
except Exception as e: except Exception as e:
logger.error(f"Failed to get cached status for {unit_id}: {e}") logger.error(f"Failed to get status for {unit_id}: {e}")
return templates.TemplateResponse("partials/slm_live_view.html", { return templates.TemplateResponse("partials/slm_live_view.html", {
"request": request, "request": request,
-70
View File
@@ -231,76 +231,6 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
logger.info(f"WebSocket proxy closed for {unit_id} (live)") logger.info(f"WebSocket proxy closed for {unit_id} (live)")
@router.websocket("/{unit_id}/monitor")
async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str):
"""
Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed).
This is the shared ~1Hz DOD feed: many clients subscribe to one device feed
(no single-connection contention) and it carries L1/L10 (which the DRD
/stream cannot). Preferred over /stream for the live view.
"""
await websocket.accept()
logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)")
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
backend_ws = None
try:
backend_ws = await websockets.connect(target_ws_url)
logger.info(f"Connected to SLMM monitor feed for {unit_id}")
async def forward_to_client():
"""Backend monitor frames -> browser."""
async for message in backend_ws:
await websocket.send_text(message)
async def watch_client():
"""Drain client frames; raises WebSocketDisconnect on close so we can
tear the pair down (the monitor feed is server->client only)."""
while True:
await websocket.receive_text()
# When EITHER side ends (browser disconnects or backend closes), cancel the
# other immediately — avoids sending into a closed socket (the
# "Unexpected ASGI message after close" race that asyncio.gather leaves open).
tasks = [asyncio.ensure_future(forward_to_client()),
asyncio.ensure_future(watch_client())]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
# Await ALL tasks (the done one AND the cancelled one) and swallow both
# the expected WebSocketDisconnect and CancelledError. CancelledError is a
# BaseException, so a bare `except Exception` misses it — that's what leaked
# the traceback on stop; and awaiting only `pending` left the done task's
# exception unretrieved.
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except websockets.exceptions.WebSocketException as e:
logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}")
try:
await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)})
except Exception:
pass
except Exception as e:
logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}")
finally:
if backend_ws:
try:
await backend_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"WebSocket monitor proxy closed for {unit_id}")
# HTTP catch-all route MUST come after specific routes (including WebSocket routes) # HTTP catch-all route MUST come after specific routes (including WebSocket routes)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_slmm(path: str, request: Request): async def proxy_to_slmm(path: str, request: Request):
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
web-app: terra-view:
build: . build: .
ports: ports:
- "8001:8001" - "8001:8001"
-214
View File
@@ -1,214 +0,0 @@
# 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()`. M1M3 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
# M1M3: read signed `portal_client` cookie -> load Client
# M4: same signature, backed by real sessions (magic-link / password)
```
**Interim "magic URL" flow (M1M3):**
- 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
@@ -1,237 +0,0 @@
# 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).
-2
View File
@@ -1,2 +0,0 @@
-r requirements.txt
pytest==8.3.3
-1
View File
@@ -10,4 +10,3 @@ 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
+1 -66
View File
@@ -42,18 +42,6 @@
</div> </div>
</div> </div>
<!-- Live Monitoring (keepalive) -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-1">Live Monitoring (keepalive)</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Keepalive runs the 1&nbsp;Hz DOD feed 24/7 (even with no viewer), which powers the live-chart
trail and continuous threshold alerts. Toggling persists and survives restarts.
</p>
<div id="monitor-list" class="text-sm">
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
</div>
</div>
<!-- Raw API tester --> <!-- Raw API tester -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6"> <div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2> <h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
@@ -144,60 +132,7 @@ async function sendRaw() {
} }
} }
async function loadMonitors() {
const el = document.getElementById('monitor-list');
try {
const r = await fetch('/api/slmm/roster');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const devices = d.devices || [];
if (!devices.length) {
el.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No devices configured.</p>';
return;
}
el.innerHTML = devices.map(dev => {
const on = !!dev.monitor_enabled;
const reach = dev.status ? dev.status.is_reachable : null;
const reachDot = reach === false
? '<span class="w-2 h-2 rounded-full bg-red-500 inline-block" title="unreachable"></span>'
: '<span class="w-2 h-2 rounded-full bg-green-500 inline-block" title="reachable"></span>';
return `
<div class="flex items-center justify-between border-b border-gray-100 dark:border-gray-700 py-2">
<div class="flex items-center gap-2">
${reachDot}
<span class="font-mono text-gray-900 dark:text-white">${_esc(dev.unit_id)}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(dev.host)}:${_esc(dev.tcp_port)}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs font-medium px-2 py-0.5 rounded ${on
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400'}">${on ? '24/7 ON' : 'OFF'}</span>
<button onclick="toggleMonitor('${_esc(dev.unit_id)}', ${!on})"
class="px-3 py-1 text-xs rounded text-white ${on
? 'bg-red-600 hover:bg-red-700' : 'bg-seismo-orange hover:bg-orange-600'}">
${on ? 'Disable' : 'Enable'}
</button>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = `<p class="text-red-600 dark:text-red-400">Failed to load devices: ${_esc(e.message)}</p>`;
}
}
async function toggleMonitor(unitId, enable) {
const action = enable ? 'start' : 'stop';
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/monitor/${action}`, { method: 'POST' });
if (!r.ok) throw new Error('HTTP ' + r.status);
await loadMonitors();
} catch (e) {
alert('Toggle failed: ' + e.message);
}
}
loadSlmmOverview(); loadSlmmOverview();
loadMonitors(); setInterval(loadSlmmOverview, 30000);
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
</script> </script>
{% endblock %} {% endblock %}
+29 -39
View File
@@ -2,14 +2,7 @@
{% if units %} {% if units %}
{% for unit in units %} {% for unit in units %}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative"> <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
<div class="absolute top-3 right-3 flex gap-2 z-10"> <div class="absolute top-3 right-3 flex gap-2">
<button onclick="event.preventDefault(); event.stopPropagation(); refreshSlmUnit('{{ unit.id }}', this);"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="Refresh {{ unit.id }} from device">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');" <button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="View live chart"> title="View live chart">
@@ -27,44 +20,41 @@
</button> </button>
</div> </div>
<a href="/slm/{{ unit.id }}" class="block pr-24"> <a href="/slm/{{ unit.id }}" class="block">
<div class="min-w-0"> <div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-2"> <div class="min-w-0 flex-1">
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span> <div class="flex items-center gap-2">
{% if unit.slm_model %} <span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span> {% if unit.slm_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
{% endif %}
</div>
{% if unit.address %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
{% elif unit.location %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
{% endif %} {% endif %}
</div> </div>
{% if unit.address %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p> {% if unit.retired %}
{% elif unit.location %} <span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p> {% elif not unit.deployed %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif unit.measurement_state == "Start" %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
{% elif unit.is_recent %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
{% else %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
{% endif %} {% endif %}
</div> </div>
<!-- Status badge + last-check on one line (moved off the top-right so it <div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
no longer collides with the refresh/chart/gear action icons). --> {% if unit.slm_last_check %}
<div class="mt-2 flex items-center gap-2 flex-wrap"> Last check: {{ unit.slm_last_check|local_datetime }}
{% if unit.retired %}
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
{% elif not unit.deployed %}
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif unit.measurement_state in ["Start", "Measure"] %}
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
{% elif unit.is_recent %}
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
{% else %} {% else %}
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span> No recent check-in
{% endif %} {% endif %}
<span class="text-xs text-gray-500 dark:text-gray-400">
{% if unit.cache_last_seen %}
Last check: {{ unit.cache_last_seen|local_datetime }}
{% elif unit.slm_last_check %}
Last check: {{ unit.slm_last_check|local_datetime }}
{% else %}
No recent check-in
{% endif %}
</span>
</div> </div>
</a> </a>
</div> </div>
+19 -113
View File
@@ -143,8 +143,6 @@
</svg> </svg>
Stop Live Stream Stop Live Stream
</button> </button>
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
</div> </div>
</div> </div>
@@ -175,17 +173,17 @@
</div> </div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4"> <div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<p id="live-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln1_label %}{{ current_status.ln1_label }}{% else %}L1{% endif %}</p> <p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
<p id="live-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400"> <p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{% if current_status and current_status.ln1 %}{{ current_status.ln1 }}{% else %}--{% endif %} {% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4"> <div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<p id="live-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln2_label %}{{ current_status.ln2_label }}{% else %}L10{% endif %}</p> <p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
<p id="live-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400"> <p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{% if current_status and current_status.ln2 %}{{ current_status.ln2 }}{% else %}--{% endif %} {% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
@@ -434,24 +432,6 @@ function initializeChart() {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0 pointRadius: 0
},
{
label: 'L1',
data: [],
borderColor: 'rgb(139, 92, 246)',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
},
{
label: 'L10',
data: [],
borderColor: 'rgb(245, 158, 11)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
} }
] ]
}, },
@@ -513,37 +493,7 @@ if (typeof window.currentWebSocket === 'undefined') {
window.currentWebSocket = null; window.currentWebSocket = null;
} }
// Backfill the chart with the recent DOD trail so it opens with context. function initLiveDataStream(unitId) {
async function backfillChart(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
if (!r.ok) return;
const d = await r.json();
const readings = d.readings || [];
if (!window.chartData) return;
for (const row of readings) {
// Trail timestamps are naive UTC; append 'Z' so they convert to local
// consistently with the live frames (which use local Date.now()).
window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
window.chartData.lp.push(parseFloat(row.lp || 0));
window.chartData.leq.push(parseFloat(row.leq || 0));
window.chartData.ln1.push(parseFloat(row.ln1 || 0));
window.chartData.ln2.push(parseFloat(row.ln2 || 0));
}
if (window.liveChart) {
window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none');
}
} catch (e) {
console.warn('Chart backfill failed:', e);
}
}
async function initLiveDataStream(unitId) {
// Close existing connection if any // Close existing connection if any
if (window.currentWebSocket) { if (window.currentWebSocket) {
window.currentWebSocket.close(); window.currentWebSocket.close();
@@ -554,24 +504,17 @@ async function initLiveDataStream(unitId) {
window.chartData.timestamps = []; window.chartData.timestamps = [];
window.chartData.lp = []; window.chartData.lp = [];
window.chartData.leq = []; window.chartData.leq = [];
window.chartData.ln1 = [];
window.chartData.ln2 = [];
} }
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) { if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
window.liveChart.data.labels = []; window.liveChart.data.labels = [];
window.liveChart.data.datasets.forEach(ds => ds.data = []); window.liveChart.data.datasets[0].data = [];
window.liveChart.data.datasets[1].data = [];
window.liveChart.update(); window.liveChart.update();
} }
// Seed the chart with recent history BEFORE opening the live socket, so live // WebSocket URL for SLMM backend via proxy
// frames append after the backfill (right order) and the chart isn't blank.
await backfillChart(unitId);
// WebSocket URL for SLMM backend via proxy.
// /monitor = the shared fan-out DOD feed (many viewers, one device connection,
// and it carries L1/L10 which the DRD /stream cannot).
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`; const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
window.currentWebSocket = new WebSocket(wsUrl); window.currentWebSocket = new WebSocket(wsUrl);
@@ -587,11 +530,7 @@ async function initLiveDataStream(unitId) {
window.currentWebSocket.onmessage = function(event) { window.currentWebSocket.onmessage = function(event) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a console.log('WebSocket data received:', data);
// 'feed_status' on each frame. Reflect status, but don't let a heartbeat
// or an 'unreachable' frame blank the cards / spike the chart with zeros.
updateFeedStatus(data.feed_status);
if (data.heartbeat || data.feed_status === 'unreachable') return;
updateLiveMetrics(data); updateLiveMetrics(data);
updateLiveChart(data); updateLiveChart(data);
} catch (error) { } catch (error) {
@@ -620,21 +559,6 @@ function stopLiveDataStream() {
} }
} }
// Reflect device reachability from the monitor feed's feed_status. Safe no-op
// if the badge element isn't on the page.
function updateFeedStatus(status) {
const el = document.getElementById('live-feed-status');
if (!el || status == null) return;
if (status === 'unreachable') {
el.textContent = 'Device offline';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
} else {
el.textContent = 'Live';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
el.style.display = '';
}
// Update metrics display // Update metrics display
function updateLiveMetrics(data) { function updateLiveMetrics(data) {
if (document.getElementById('live-lp')) { if (document.getElementById('live-lp')) {
@@ -646,20 +570,11 @@ function updateLiveMetrics(data) {
if (document.getElementById('live-lmax')) { if (document.getElementById('live-lmax')) {
document.getElementById('live-lmax').textContent = data.lmax || '--'; document.getElementById('live-lmax').textContent = data.lmax || '--';
} }
// Only update Ln values when the frame actually carries them. DRD stream if (document.getElementById('live-lmin')) {
// frames omit percentiles (DOD-only), so without this guard a live stream document.getElementById('live-lmin').textContent = data.lmin || '--';
// would blank L1/L10 over the values rendered from the cached DOD snapshot.
if (data.ln1 != null && document.getElementById('live-ln1')) {
document.getElementById('live-ln1').textContent = data.ln1;
} }
if (data.ln1_label && document.getElementById('live-ln1-label')) { if (document.getElementById('live-lpeak')) {
document.getElementById('live-ln1-label').textContent = data.ln1_label; document.getElementById('live-lpeak').textContent = data.lpeak || '--';
}
if (data.ln2 != null && document.getElementById('live-ln2')) {
document.getElementById('live-ln2').textContent = data.ln2;
}
if (data.ln2_label && document.getElementById('live-ln2-label')) {
document.getElementById('live-ln2-label').textContent = data.ln2_label;
} }
} }
@@ -668,9 +583,7 @@ if (typeof window.chartData === 'undefined') {
window.chartData = { window.chartData = {
timestamps: [], timestamps: [],
lp: [], lp: [],
leq: [], leq: []
ln1: [],
ln2: []
}; };
} }
@@ -680,17 +593,12 @@ function updateLiveChart(data) {
window.chartData.timestamps.push(now.toLocaleTimeString()); window.chartData.timestamps.push(now.toLocaleTimeString());
window.chartData.lp.push(parseFloat(data.lp || 0)); window.chartData.lp.push(parseFloat(data.lp || 0));
window.chartData.leq.push(parseFloat(data.leq || 0)); window.chartData.leq.push(parseFloat(data.leq || 0));
window.chartData.ln1.push(parseFloat(data.ln1 || 0));
window.chartData.ln2.push(parseFloat(data.ln2 || 0));
// Keep a rolling window large enough to hold the ~2h backfill (one point/min) // Keep only last 60 data points
// plus a good run of live points before the oldest scroll off. if (window.chartData.timestamps.length > 60) {
if (window.chartData.timestamps.length > 600) {
window.chartData.timestamps.shift(); window.chartData.timestamps.shift();
window.chartData.lp.shift(); window.chartData.lp.shift();
window.chartData.leq.shift(); window.chartData.leq.shift();
window.chartData.ln1.shift();
window.chartData.ln2.shift();
} }
// Update chart if available // Update chart if available
@@ -698,8 +606,6 @@ function updateLiveChart(data) {
window.liveChart.data.labels = window.chartData.timestamps; window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp; window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq; window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none'); window.liveChart.update('none');
} }
} }
+3 -5
View File
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
if (typeof checkFTPStatus === 'function') { if (typeof checkFTPStatus === 'function') {
checkFTPStatus(unitId); checkFTPStatus(unitId);
} }
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) { if (typeof htmx !== 'undefined') {
htmx.trigger('#slm-list', 'load'); htmx.trigger('#slm-list', 'load');
} }
}, 1500); }, 1500);
@@ -604,10 +604,8 @@ async function toggleSLMDeployed() {
successDiv.classList.remove('hidden'); successDiv.classList.remove('hidden');
setTimeout(() => successDiv.classList.add('hidden'), 3000); setTimeout(() => successDiv.classList.add('hidden'), 3000);
// Refresh any SLM list on the page (only if one is actually present — // Refresh any SLM list on the page
// the detail/dashboard pages have no #slm-list, and htmx.trigger on a if (typeof htmx !== 'undefined') {
// null target throws "can't access property dispatchEvent, e is null").
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
htmx.trigger('#slm-list', 'load'); htmx.trigger('#slm-list', 'load');
} }
} catch (error) { } catch (error) {
-19
View File
@@ -1,19 +0,0 @@
{% extends "portal/base.html" %}
{% block title %}Access{% endblock %}
{% block content %}
<div class="max-w-md mx-auto mt-20 text-center reveal">
<div class="panel inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6">
<svg class="w-7 h-7 text-[var(--text-dim)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
{% if reason == "invalid" %}
<h1 class="text-2xl font-bold tracking-tight mb-2">This link isn't valid</h1>
<p class="text-[var(--text-dim)] text-sm leading-relaxed">The access link is expired or has been revoked.<br>Please contact TMI for a new link.</p>
{% else %}
<h1 class="text-2xl font-bold tracking-tight mb-2">Access link required</h1>
<p class="text-[var(--text-dim)] text-sm leading-relaxed">Open the monitoring link TMI sent you to view your locations.</p>
{% endif %}
</div>
{% endblock %}
-196
View File
@@ -1,196 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Monitoring{% endblock %} · TMI</title>
<!-- apply saved theme before paint (no flash); light is the default -->
<script>(function(){var t=localStorage.getItem('portal-theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: { extend: {
colors: { seismo: { orange: '#f48b1c', navy: '#142a66', burgundy: '#7d234d' } },
fontFamily: {
sans: ['"Hanken Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
} }
}
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
<meta name="theme-color" content="#eef2f9">
<style>
/* ---- dark (default) ---- */
:root {
--bg: #080b14;
--grid: rgba(124, 146, 188, 0.045);
--aurora-1: rgba(20, 42, 102, 0.55);
--aurora-2: rgba(125, 35, 77, 0.18);
--text: #e7ecf6;
--text-dim: #8c98b0;
--border: rgba(124, 146, 188, 0.14);
--border-bright: rgba(168, 188, 224, 0.30);
--panel-a: rgba(24, 33, 54, 0.72);
--panel-b: rgba(12, 18, 31, 0.62);
--panel-inset: rgba(255, 255, 255, 0.05);
--panel-shadow: 0 22px 48px -28px rgba(0, 0, 0, 0.85);
--header-bg: rgba(8, 11, 20, 0.72);
--accent: #f48b1c;
--accent-glow: rgba(244, 139, 28, 0.40);
--lvl-ok: #34d399; --lvl-warn: #fbbf24; --lvl-bad: #f87171;
--m-lp: #60a5fa; --m-lmax: #f87171; --m-l1: #c084fc; --m-l10: #fbbf24;
}
/* ---- light (cool) — solid cards on a cool ground ---- */
html[data-theme="light"] {
--bg: #eef2f9; /* cool light */
--grid: rgba(20, 42, 102, 0.05); /* cool faint grid */
--aurora-1: rgba(120, 150, 220, 0.18); /* cool wash */
--aurora-2: rgba(244, 139, 28, 0.08); /* faint brand accent */
--text: #16203a; /* cool navy ink */
--text-dim: #5d6b86; /* cool muted */
--border: rgba(20, 42, 102, 0.13);
--border-bright: rgba(20, 42, 102, 0.18);
--panel-a: #ffffff; /* solid — kept from the un-ghosting pass */
--panel-b: #f7f9fc;
--panel-inset: rgba(255, 255, 255, 0.9);
--panel-shadow: 0 14px 30px -16px rgba(40, 55, 95, 0.22), 0 2px 6px -2px rgba(40, 55, 95, 0.07);
--header-bg: rgba(238, 242, 249, 0.85);
--lvl-ok: #16a34a; --lvl-warn: #d97706; --lvl-bad: #dc2626;
--m-lp: #2563eb; --m-lmax: #dc2626; --m-l1: #9333ea; --m-l10: #d97706;
}
/* On light, the hover-lift shadow wants cool depth (the dark one vanishes on light). */
html[data-theme="light"] .panel-hover:hover {
box-shadow: 0 22px 44px -20px rgba(40, 55, 95, 0.26), 0 0 0 1px var(--accent-glow);
}
html, body { height: 100%; }
body {
margin: 0;
color: var(--text);
font-family: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;
font-feature-settings: "ss01";
background-color: var(--bg);
background-image:
radial-gradient(1100px 560px at 50% -12%, var(--aurora-1), transparent 68%),
radial-gradient(700px 400px at 88% 8%, var(--aurora-2), transparent 70%),
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: auto, auto, 46px 46px, 46px 46px;
background-attachment: fixed;
transition: background-color .3s ease, color .3s ease;
}
::selection { background: rgba(244, 139, 28, 0.30); }
.font-mono, .reading { font-family: "IBM Plex Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.panel {
position: relative;
background: linear-gradient(180deg, var(--panel-a), var(--panel-b));
border: 1px solid var(--border);
border-radius: 16px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 1px 0 var(--panel-inset) inset, var(--panel-shadow);
}
.panel::before {
content: ''; position: absolute; inset: 0 0 auto 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--border-bright), transparent);
}
.panel-hover { transition: transform .22s ease, border-color .22s ease, box-shadow .22s ease; }
.panel-hover:hover {
transform: translateY(-3px);
border-color: rgba(244, 139, 28, 0.55);
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--accent-glow);
}
.hairline { border-top: 1px solid var(--border); }
/* metric accent colors (flip per theme) */
.c-lp { color: var(--m-lp); } .c-lmax { color: var(--m-lmax); }
.c-l1 { color: var(--m-l1); } .c-l10 { color: var(--m-l10); }
.live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 0 var(--accent-glow); animation: pulse 2.2s infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 var(--accent-glow); } 70% { box-shadow: 0 0 0 9px rgba(244, 139, 28, 0); } 100% { box-shadow: 0 0 0 0 rgba(244, 139, 28, 0); } }
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.reveal { opacity: 0; animation: rise .55s cubic-bezier(.2, .7, .2, 1) forwards; }
.signal-bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
.signal-bars i { width: 3px; background: var(--accent); border-radius: 1px; animation: bars 1.4s ease-in-out infinite; }
.signal-bars i:nth-child(1) { height: 40%; } .signal-bars i:nth-child(2) { height: 70%; animation-delay: .2s; }
.signal-bars i:nth-child(3) { height: 100%; animation-delay: .4s; } .signal-bars i:nth-child(4) { height: 55%; animation-delay: .6s; }
@keyframes bars { 0%, 100% { transform: scaleY(.5); opacity: .7; } 50% { transform: scaleY(1); opacity: 1; } }
.theme-toggle { color: var(--text-dim); transition: color .2s ease, background .2s ease; }
.theme-toggle:hover { color: var(--text); }
html[data-theme="light"] .moon { display: none; }
html[data-theme="dark"] .sun, :root:not([data-theme="light"]) .sun { display: none; }
/* Leaflet polish (dark default; .leaflet-light tweaks tooltip for light) */
.leaflet-container { background: var(--bg) !important; }
.leaflet-tooltip { background: var(--panel-a); border: 1px solid var(--border-bright); color: var(--text); box-shadow: none; font-family: inherit; font-size: 12px; }
.leaflet-tooltip-top::before { border-top-color: var(--border-bright); }
.leaflet-control-attribution { background: rgba(0,0,0,0.25) !important; color: var(--text-dim) !important; }
.leaflet-control-attribution a { color: var(--text-dim) !important; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: rgba(124, 146, 188, 0.22); border-radius: 9px; }
</style>
{% block head %}{% endblock %}
</head>
<body class="min-h-full antialiased">
<header class="sticky top-0 z-30 border-b border-[var(--border)] bg-[var(--header-bg)] backdrop-blur-xl">
<div class="max-w-5xl mx-auto px-5 py-3.5 flex items-center justify-between">
<a href="/portal" class="flex items-center gap-2.5">
<span class="signal-bars"><i></i><i></i><i></i><i></i></span>
<span class="font-semibold tracking-tight text-[15px]">
TMI <span class="text-[var(--text-dim)] font-normal">Monitoring</span>
{% if client %}<span class="text-[var(--text-dim)] font-normal mx-0.5">/</span>
<span class="text-seismo-orange">{{ client.name }}</span>{% endif %}
</span>
</a>
<div class="flex items-center gap-1.5">
<button onclick="togglePortalTheme()" class="theme-toggle p-2 rounded-lg" title="Toggle light / dark" aria-label="Toggle theme">
<svg class="moon w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
<svg class="sun w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</button>
{% if client %}
<a href="/portal/logout" class="text-[13px] text-[var(--text-dim)] hover:text-[var(--text)] transition-colors px-2">Sign out</a>
{% endif %}
</div>
</div>
</header>
<main class="max-w-5xl mx-auto px-5 py-8">
{% block content %}{% endblock %}
</main>
<footer class="max-w-5xl mx-auto px-5 py-10 text-[11px] text-[var(--text-dim)] flex items-center gap-2 opacity-70">
<span class="w-1 h-1 rounded-full bg-[var(--text-dim)]"></span>
Read-only monitoring view · data provided as-is for informational purposes
</footer>
<script>
// Theme toggle. Pages can listen for 'portal-theme' to re-skin canvases/maps.
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
// HTML-escape operator-set strings (location/rule names) before innerHTML/tooltip injection.
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
-335
View File
@@ -1,335 +0,0 @@
{% extends "portal/base.html" %}
{% block title %}{{ location.name }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<a href="/portal" class="reveal inline-flex items-center gap-1.5 text-sm text-[var(--text-dim)] hover:text-[var(--text)] transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
All locations
</a>
<div class="reveal mt-3 flex flex-wrap items-end justify-between gap-3">
<h1 class="text-3xl font-bold tracking-tight">{{ location.name }}</h1>
<div class="flex items-center gap-2.5">
<span id="p-badge" class="hidden"></span>
<span id="p-fresh" class="text-[var(--text-dim)] font-mono text-xs"></span>
</div>
</div>
{% if not has_device %}
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No device is currently assigned to this location.</div>
{% else %}
<div id="p-alarm-banner" class="hidden reveal mt-5 px-4 py-3 rounded-xl bg-[rgba(220,38,38,0.10)] border border-[rgba(220,38,38,0.32)] text-[var(--lvl-bad)] text-sm flex items-center gap-2.5">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/>
</svg>
<span id="p-alarm-text" class="font-medium">Currently above threshold.</span>
</div>
<!-- Hero console: Leq primary + instrument strip -->
<div class="panel reveal mt-5 p-6 sm:p-7" style="animation-delay:60ms">
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-1.5">Leq · average</div>
<div class="flex items-baseline gap-2.5">
<span id="p-leq" class="reading text-6xl sm:text-7xl leading-none font-semibold">--</span>
<span class="text-sm text-[var(--text-dim)] font-mono">dB</span>
</div>
<div class="hairline mt-6 pt-5 grid grid-cols-2 sm:grid-cols-4 gap-5">
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lp · instant</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-lp" class="reading text-2xl font-semibold c-lp">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lmax · peak</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-lmax" class="reading text-2xl font-semibold c-lmax">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L1</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln1" class="reading text-2xl font-semibold c-l1">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L10</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln2" class="reading text-2xl font-semibold c-l10">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
</div>
</div>
<!-- Live trace -->
<div class="panel reveal mt-5 overflow-hidden" style="animation-delay:120ms">
<div class="px-5 pt-4 text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono">Live trace · last 2h</div>
<div class="relative px-3 pb-3 pt-2" style="min-height: 340px;">
<canvas id="p-chart"></canvas>
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-[rgba(8,11,20,0.78)] rounded-xl backdrop-blur-sm">
<button onclick="resumeStream()"
class="px-4 py-2 rounded-lg bg-seismo-orange/15 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/25 text-sm font-medium transition-colors">
&#9208; 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 %}
-192
View File
@@ -1,192 +0,0 @@
{% extends "portal/base.html" %}
{% block title %}Your locations{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{% endblock %}
{% block content %}
<div class="reveal">
<div class="text-[11px] uppercase tracking-[0.2em] text-seismo-orange/80 font-mono mb-2">Live monitoring</div>
<h1 class="text-3xl font-bold tracking-tight">Your locations</h1>
<p class="text-[var(--text-dim)] text-sm mt-1">Real-time sound levels across your active monitoring sites.</p>
</div>
{% if locations %}
<!-- Status rollup (filled live from the per-location /live fetches) -->
<div id="rollup" class="hidden mt-6 mb-6 flex flex-wrap items-center gap-2.5">
<div class="panel px-4 py-2.5 flex items-center gap-2.5">
<span class="text-[var(--text-dim)] text-[10px] uppercase tracking-[0.15em]">Locations</span>
<b id="r-total" class="reading text-lg font-semibold">&ndash;</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">&ndash;</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">&ndash;</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">&ndash;</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&nbsp;Leq</span>
</div>
<div class="loc-fresh text-[11px] text-[var(--text-dim)]/70 mt-2 font-mono">&nbsp;</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 += ` &middot; ${esc(st.leqStr)} dB Leq`;
else if (st.status === 'stopped') label += ' &middot; stopped';
else if (st.status === 'nodevice') label += ' &middot; no device';
else label += ' &middot; 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 = '&nbsp;';
} 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 %}
-26
View File
@@ -1,26 +0,0 @@
{% 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 %}
+1 -115
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
<div class="mb-6 flex items-center justify-between gap-3"> <div class="mb-6">
<nav class="flex items-center space-x-2 text-sm"> <nav class="flex items-center space-x-2 text-sm">
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center"> <a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -17,28 +17,6 @@
</svg> </svg>
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span> <span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
</nav> </nav>
<!-- Client portal 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) -->
@@ -2096,97 +2074,5 @@ 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 %}
-263
View File
@@ -112,267 +112,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Alerts -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">Alerts
<span id="alert-state-badge" class="hidden text-xs px-2 py-0.5 rounded-full"></span>
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">Threshold rules evaluated on this device's live feed. An enabled alert keeps the device monitored 24/7.</p>
</div>
<button onclick="openAlertForm()" type="button"
class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">+ Add alert</button>
</div>
<div id="alert-rules-list" class="space-y-2"></div>
<!-- create / edit form -->
<div id="alert-form" class="hidden mt-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900/40">
<input type="hidden" id="ar-id">
<div class="grid sm:grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Name</label>
<input id="ar-name" type="text" placeholder="e.g. Night noise limit"
class="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-gray-800 dark:text-gray-200">
</div>
<label class="flex items-end gap-2 text-sm text-gray-700 dark:text-gray-300 pb-1">
<input type="checkbox" id="ar-enabled" checked class="rounded"> Enabled
</label>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>Alert when</span>
<select id="ar-metric" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<option value="leq">Leq</option><option value="lp">Lp</option>
<option value="lmax">Lmax</option><option value="lpeak">Lpeak</option>
<option value="ln1">L1</option><option value="ln2">L10</option>
</select>
<span>is</span>
<select id="ar-comparison" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<option value="above">above</option><option value="below">below</option>
</select>
<input id="ar-threshold" type="number" step="0.1" placeholder="65"
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>dB</span>
<span>for</span>
<input id="ar-duration" type="number" min="0" value="0"
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>seconds</span>
</div>
<div class="mt-3">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" id="ar-sched-on" onchange="toggleSchedule()" class="rounded"> Only during certain hours
</label>
<div id="ar-sched" class="hidden mt-2 flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>from</span><input id="ar-start" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<span>to</span><input id="ar-end" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<span class="ml-2">on</span>
<span id="ar-days" class="flex gap-1"></span>
</div>
</div>
<details class="mt-3 text-sm text-gray-600 dark:text-gray-400">
<summary class="cursor-pointer select-none">Advanced</summary>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span>Clear margin</span><input id="ar-margin" type="number" step="0.1" value="2"
class="w-16 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>dB (hysteresis)</span>
<span>Cooldown</span><input id="ar-cooldown" type="number" min="0" value="300"
class="w-20 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>s</span>
</div>
</details>
<div class="mt-4 flex gap-2">
<button onclick="saveAlertRule()" type="button" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Save</button>
<button onclick="closeAlertForm()" type="button" class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700">Cancel</button>
</div>
</div>
<!-- Alert history -->
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">History</h3>
<button onclick="loadAlertEvents()" type="button" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Refresh</button>
</div>
<div id="alert-events" class="space-y-2"></div>
</div>
</div>
<script>
const ALERT_UNIT = "{{ unit_id }}";
const METRIC_LABELS = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Mon=0 .. Sun=6
// Render the day checkboxes once.
(function () {
const wrap = document.getElementById('ar-days');
DAY_LABELS.forEach((lbl, i) => {
const l = document.createElement('label');
l.className = 'inline-flex items-center gap-0.5';
l.innerHTML = `<input type="checkbox" id="ar-day-${i}" class="rounded"><span class="ml-0.5">${lbl}</span>`;
wrap.appendChild(l);
});
})();
function condText(r) {
const m = METRIC_LABELS[r.metric] || r.metric;
let s = `${m} ${r.comparison} ${r.threshold_db} dB`;
if (r.duration_s) s += ` for ${r.duration_s}s`;
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}${r.schedule_end}`;
return s;
}
function renderRule(r) {
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
row.innerHTML = `<div class="min-w-0">
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${r.name}${r.enabled ? '' : ' <span class="text-xs text-gray-400">(disabled)</span>'}</div>
<div class="text-xs text-gray-500">${condText(r)}</div></div>
<div class="shrink-0 flex items-center gap-3 text-xs">
<button data-act="edit" class="text-seismo-orange hover:underline">Edit</button>
<button data-act="del" class="text-red-600 hover:underline">Delete</button>
</div>`;
row.querySelector('[data-act="edit"]').onclick = () => openAlertForm(r);
row.querySelector('[data-act="del"]').onclick = () => deleteAlertRule(r.id);
return row;
}
async function loadAlertRules() {
const list = document.getElementById('alert-rules-list');
try {
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules`)).json();
const rules = j.rules || [];
if (!rules.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts configured.</div>'; return; }
list.innerHTML = '';
rules.forEach(r => list.appendChild(renderRule(r)));
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load alerts.</div>'; }
}
function toggleSchedule() {
document.getElementById('ar-sched').classList.toggle('hidden', !document.getElementById('ar-sched-on').checked);
}
function openAlertForm(r) {
document.getElementById('alert-form').classList.remove('hidden');
document.getElementById('ar-id').value = r ? r.id : '';
document.getElementById('ar-name').value = r ? r.name : '';
document.getElementById('ar-metric').value = r ? r.metric : 'leq';
document.getElementById('ar-comparison').value = r ? r.comparison : 'above';
document.getElementById('ar-threshold').value = (r && r.threshold_db != null) ? r.threshold_db : '';
document.getElementById('ar-duration').value = r ? r.duration_s : 0;
document.getElementById('ar-enabled').checked = r ? r.enabled : true;
document.getElementById('ar-margin').value = r ? r.clear_margin_db : 2;
document.getElementById('ar-cooldown').value = r ? r.cooldown_s : 300;
const hasSched = !!(r && r.schedule_start && r.schedule_end);
document.getElementById('ar-sched-on').checked = hasSched;
document.getElementById('ar-start').value = hasSched ? r.schedule_start : '';
document.getElementById('ar-end').value = hasSched ? r.schedule_end : '';
const days = (r && r.schedule_days) ? r.schedule_days.split(',') : [];
DAY_LABELS.forEach((_, i) => { document.getElementById('ar-day-' + i).checked = days.includes(String(i)); });
toggleSchedule();
}
function closeAlertForm() { document.getElementById('alert-form').classList.add('hidden'); }
async function saveAlertRule() {
const id = document.getElementById('ar-id').value;
const threshold = parseFloat(document.getElementById('ar-threshold').value);
if (isNaN(threshold)) { if (window.showToast) showToast('Enter a threshold', 'error'); return; }
const schedOn = document.getElementById('ar-sched-on').checked;
const days = DAY_LABELS.map((_, i) => document.getElementById('ar-day-' + i).checked ? i : null).filter(v => v !== null);
const payload = {
name: document.getElementById('ar-name').value || 'Alert',
metric: document.getElementById('ar-metric').value,
comparison: document.getElementById('ar-comparison').value,
threshold_db: threshold,
duration_s: parseInt(document.getElementById('ar-duration').value) || 0,
clear_margin_db: parseFloat(document.getElementById('ar-margin').value) || 2,
cooldown_s: parseInt(document.getElementById('ar-cooldown').value) || 300,
schedule_start: schedOn ? (document.getElementById('ar-start').value || null) : null,
schedule_end: schedOn ? (document.getElementById('ar-end').value || null) : null,
schedule_days: (schedOn && days.length) ? days.join(',') : null,
enabled: document.getElementById('ar-enabled').checked,
};
const url = id ? `/api/slmm/${ALERT_UNIT}/alerts/rules/${id}` : `/api/slmm/${ALERT_UNIT}/alerts/rules`;
try {
const r = await fetch(url, { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!r.ok) throw new Error('save failed');
closeAlertForm(); loadAlertRules();
if (window.showToast) showToast('Alert saved', 'success');
} catch (e) { if (window.showToast) showToast('Failed to save alert', 'error'); }
}
async function deleteAlertRule(id) {
if (!confirm('Delete this alert rule?')) return;
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules/${id}`, { method: 'DELETE' }); loadAlertRules(); }
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
}
// ---- alert history (events) ----------------------------------------------
function fmtAlertTime(iso) {
if (!iso) return '';
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString();
}
function updateAlertState(events) {
const badge = document.getElementById('alert-state-badge');
badge.classList.remove('hidden');
const active = events.filter(e => e.status === 'active').length;
if (active) {
badge.textContent = `● ${active} active`;
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
} else {
badge.textContent = '✓ All clear';
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
}
function renderEvent(e) {
const m = METRIC_LABELS[e.metric] || e.metric;
const active = e.status === 'active';
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border ' +
(active ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
: 'border-slate-200 dark:border-slate-700');
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
const ack = e.acknowledged_at ? ` · ack'd${e.acknowledged_by ? ' by ' + e.acknowledged_by : ''}` : '';
row.innerHTML = `<div class="min-w-0">
<div class="text-sm truncate">
<span class="${active ? 'text-red-600 dark:text-red-400 font-medium' : 'text-gray-800 dark:text-gray-200'}">${e.rule_name || 'Alert'}</span>
<span class="text-xs text-gray-500"> · ${m} ${e.threshold_db} dB</span>
</div>
<div class="text-xs text-gray-500">${when}${peak}${ack}</div></div>`;
if (!e.acknowledged_at) {
const btn = document.createElement('button');
btn.className = 'shrink-0 text-xs text-seismo-orange hover:underline';
btn.textContent = 'Ack';
btn.onclick = () => ackEvent(e.id);
row.appendChild(btn);
}
return row;
}
async function loadAlertEvents() {
const list = document.getElementById('alert-events');
try {
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events?limit=50`)).json();
const events = j.events || [];
updateAlertState(events);
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts have fired.</div>'; return; }
list.innerHTML = '';
events.forEach(e => list.appendChild(renderEvent(e)));
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load history.</div>'; }
}
async function ackEvent(id) {
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events/${id}/ack`, { method: 'POST' }); loadAlertEvents(); }
catch (e) { if (window.showToast) showToast('Failed to acknowledge', 'error'); }
}
loadAlertRules();
loadAlertEvents();
setInterval(loadAlertEvents, 20000); // surface new breaches / clears
</script>
{% endblock %} {% endblock %}
+40 -281
View File
@@ -51,31 +51,13 @@
<!-- Live Measurement Chart - shows when a device is selected --> <!-- Live Measurement Chart - shows when a device is selected -->
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8"> <div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
<div class="flex items-start justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white"> <button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
Live Measurements <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span id="panel-unit-id" class="text-seismo-orange"></span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</h2> </svg>
<!-- Measuring state + cache freshness (populated from cached /status, no device hit) --> </button>
<div class="mt-1 flex items-center gap-2 text-sm">
<span id="panel-measuring-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
<span id="panel-freshness" class="text-gray-500 dark:text-gray-400"></span>
</div>
</div>
<div class="flex items-center gap-3">
<button onclick="refreshDashboardPanel()" title="Refresh from device"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange">
<svg id="panel-refresh-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div> </div>
<!-- Current Metrics --> <!-- Current Metrics -->
@@ -99,14 +81,14 @@
</div> </div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4"> <div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<p id="chart-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L1</p> <p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
<p id="chart-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p> <p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4"> <div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<p id="chart-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L10</p> <p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
<p id="chart-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p> <p id="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
</div> </div>
@@ -168,18 +150,9 @@ window.selectedUnitId = null;
window.dashboardChartData = { window.dashboardChartData = {
timestamps: [], timestamps: [],
lp: [], lp: [],
leq: [], leq: []
ln1: [],
ln2: []
}; };
// Parse a metric to a number, or null (so a missing/"-.-" percentile leaves a gap
// in the line instead of dropping it to 0).
function numOrNull(v) {
const f = parseFloat(v);
return isNaN(f) ? null : f;
}
// Initialize Chart.js // Initialize Chart.js
function initializeDashboardChart() { function initializeDashboardChart() {
if (typeof Chart === 'undefined') { if (typeof Chart === 'undefined') {
@@ -221,26 +194,6 @@ function initializeDashboardChart() {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0 pointRadius: 0
},
{
label: 'L1',
data: [],
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
spanGaps: true
},
{
label: 'L10',
data: [],
borderColor: 'rgb(249, 115, 22)',
backgroundColor: 'rgba(249, 115, 22, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
spanGaps: true
} }
] ]
}, },
@@ -291,24 +244,12 @@ function showLiveChart(unitId) {
initializeDashboardChart(); initializeDashboardChart();
} }
// Reset data for the newly-selected unit (clears any prior unit's line) // Reset data
window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] }; window.dashboardChartData = {
if (window.dashboardChart) { timestamps: [],
window.dashboardChart.data.labels = []; lp: [],
window.dashboardChart.data.datasets.forEach(ds => ds.data = []); leq: []
window.dashboardChart.update('none'); };
}
// Name the unit; clear stale status until the cache read returns
const unitLabel = document.getElementById('panel-unit-id');
if (unitLabel) unitLabel.textContent = '· ' + unitId;
setPanelStatus(null, null);
// Populate immediately from CACHE (no device hit): KPI cards + chart trail.
prefillDashboardPanel(unitId);
backfillDashboardChart(unitId);
// Keep the cards updating from cache (~15s) without opening a device stream.
startPanelCachePolling(unitId);
// Scroll to chart // Scroll to chart
panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -316,7 +257,6 @@ function showLiveChart(unitId) {
function closeLiveChart() { function closeLiveChart() {
stopDashboardStream(); stopDashboardStream();
stopPanelCachePolling();
document.getElementById('live-chart-panel').classList.add('hidden'); document.getElementById('live-chart-panel').classList.add('hidden');
window.selectedUnitId = null; window.selectedUnitId = null;
} }
@@ -330,12 +270,17 @@ function startDashboardStream() {
window.dashboardWebSocket.close(); window.dashboardWebSocket.close();
} }
// The live WS takes over from the cache poller; keep the backfilled trail on // Reset chart data
// the chart so the live frames continue the line instead of blanking it. window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
stopPanelCachePolling(); if (window.dashboardChart) {
window.dashboardChart.data.labels = [];
window.dashboardChart.data.datasets[0].data = [];
window.dashboardChart.data.datasets[1].data = [];
window.dashboardChart.update();
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`; const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
window.dashboardWebSocket = new WebSocket(wsUrl); window.dashboardWebSocket = new WebSocket(wsUrl);
@@ -348,10 +293,6 @@ function startDashboardStream() {
window.dashboardWebSocket.onmessage = function(event) { window.dashboardWebSocket.onmessage = function(event) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// /monitor sends keepalive 'heartbeat' frames (no metrics) and a per-frame
// 'feed_status'; skip heartbeats and offline frames so they don't blank the
// metrics or spike the chart with zeros.
if (data.heartbeat || data.feed_status === 'unreachable') return;
updateDashboardMetrics(data); updateDashboardMetrics(data);
updateDashboardChart(data); updateDashboardChart(data);
} catch (error) { } catch (error) {
@@ -375,219 +316,37 @@ function stopDashboardStream() {
window.dashboardWebSocket.close(); window.dashboardWebSocket.close();
window.dashboardWebSocket = null; window.dashboardWebSocket = null;
} }
// Fall back to cache polling so the cards keep refreshing while the panel is open.
if (window.selectedUnitId && !document.getElementById('live-chart-panel').classList.contains('hidden')) {
startPanelCachePolling(window.selectedUnitId);
}
} }
function updateDashboardMetrics(data) { function updateDashboardMetrics(data) {
document.getElementById('chart-lp').textContent = data.lp || '--'; document.getElementById('chart-lp').textContent = data.lp || '--';
document.getElementById('chart-leq').textContent = data.leq || '--'; document.getElementById('chart-leq').textContent = data.leq || '--';
document.getElementById('chart-lmax').textContent = data.lmax || '--'; document.getElementById('chart-lmax').textContent = data.lmax || '--';
// Guard: DRD stream frames omit percentiles, so only overwrite when present document.getElementById('chart-lmin').textContent = data.lmin || '--';
// (else the live stream blanks L1/L10 over the cached DOD snapshot values). document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
if (data.ln1 != null) document.getElementById('chart-ln1').textContent = data.ln1;
if (data.ln2 != null) document.getElementById('chart-ln2').textContent = data.ln2;
if (data.ln1_label) document.getElementById('chart-ln1-label').textContent = data.ln1_label;
if (data.ln2_label) document.getElementById('chart-ln2-label').textContent = data.ln2_label;
} }
function updateDashboardChart(data) { function updateDashboardChart(data) {
const cd = window.dashboardChartData;
const now = new Date(); const now = new Date();
cd.timestamps.push(now.toLocaleTimeString()); window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
cd.lp.push(numOrNull(data.lp)); window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
cd.leq.push(numOrNull(data.leq)); window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
// /monitor (DOD) frames carry ln1/ln2; a DRD frame would omit them -> null gap.
cd.ln1.push(numOrNull(data.ln1));
cd.ln2.push(numOrNull(data.ln2));
// Keep a generous window (backfill seeds up to ~120 points from the 2h trail). // Keep only last 60 data points
if (cd.timestamps.length > 600) { if (window.dashboardChartData.timestamps.length > 60) {
cd.timestamps.shift(); window.dashboardChartData.timestamps.shift();
cd.lp.shift(); window.dashboardChartData.lp.shift();
cd.leq.shift(); window.dashboardChartData.leq.shift();
cd.ln1.shift();
cd.ln2.shift();
} }
if (window.dashboardChart) { if (window.dashboardChart) {
window.dashboardChart.data.labels = cd.timestamps; window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
window.dashboardChart.data.datasets[0].data = cd.lp; window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
window.dashboardChart.data.datasets[1].data = cd.leq; window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
window.dashboardChart.data.datasets[2].data = cd.ln1;
window.dashboardChart.data.datasets[3].data = cd.ln2;
window.dashboardChart.update('none'); window.dashboardChart.update('none');
} }
} }
// ---- Cached-data panel population (no device hit) -----------------------
// Fill the KPI cards + measuring/freshness from the cached NL43Status snapshot.
async function prefillDashboardPanel(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/status`);
if (!r.ok) { // 404 = device has never reported yet
setPanelStatus(null, null);
return;
}
const d = (await r.json()).data || {};
updateDashboardMetrics(d); // lp/leq/lmax/ln1/ln2 (ln guards keep cached percentiles)
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setPanelStatus(measuring, d.last_seen);
} catch (e) {
console.warn('Panel cache prefill failed:', e);
}
}
// Seed the chart from the downsampled DOD trail so it shows recent trend on open.
async function backfillDashboardChart(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
if (!r.ok) return;
const readings = (await r.json()).readings || [];
const cd = window.dashboardChartData;
if (!cd) return;
for (const row of readings) {
// Trail timestamps are naive UTC; append 'Z' to render in local time
// consistently with the live frames (which use local Date.now()).
cd.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
cd.lp.push(numOrNull(row.lp));
cd.leq.push(numOrNull(row.leq));
cd.ln1.push(numOrNull(row.ln1));
cd.ln2.push(numOrNull(row.ln2));
}
if (window.dashboardChart) {
window.dashboardChart.data.labels = cd.timestamps;
window.dashboardChart.data.datasets[0].data = cd.lp;
window.dashboardChart.data.datasets[1].data = cd.leq;
window.dashboardChart.data.datasets[2].data = cd.ln1;
window.dashboardChart.data.datasets[3].data = cd.ln2;
window.dashboardChart.update('none');
}
} catch (e) {
console.warn('Panel chart backfill failed:', e);
}
}
// Measuring badge + "as of <time> (Xm ago)" freshness, so a cached value is never
// mistaken for a live one. measuring: true | false | null(unknown).
function setPanelStatus(measuring, lastSeenIso) {
const badge = document.getElementById('panel-measuring-badge');
const fresh = document.getElementById('panel-freshness');
if (badge) {
if (measuring === null) {
badge.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full';
badge.textContent = '';
} else if (measuring) {
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
badge.textContent = '● Measuring';
} else {
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
badge.textContent = '■ Stopped';
}
}
if (fresh) fresh.innerHTML = fmtFreshness(lastSeenIso);
}
// Human "x ago" with a staleness hint. Cached timestamps are naive UTC.
function fmtFreshness(lastSeenIso) {
if (!lastSeenIso) return '<span class="text-gray-400">no cached reading yet</span>';
const t = new Date(lastSeenIso.endsWith('Z') ? lastSeenIso : lastSeenIso + 'Z');
const secs = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
let ago, stale = false;
if (secs < 10) ago = 'just now';
else if (secs < 60) ago = secs + 's ago';
else if (secs < 3600) { ago = Math.round(secs / 60) + 'm ago'; stale = secs >= 300; }
else { ago = Math.round(secs / 3600) + 'h ago'; stale = true; }
const cls = stale ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400';
const tag = stale ? ' · cached' : '';
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${tag})</span>`;
}
// Cache polling: refresh the cards from cache every 15s while the panel is open
// and not live-streaming. Pure cache reads — no device contention.
function startPanelCachePolling(unitId) {
stopPanelCachePolling();
window.panelCacheTimer = setInterval(() => {
if (window.selectedUnitId) prefillDashboardPanel(window.selectedUnitId);
}, 15000);
}
function stopPanelCachePolling() {
if (window.panelCacheTimer) { clearInterval(window.panelCacheTimer); window.panelCacheTimer = null; }
}
// ---- On-demand device refresh (the per-unit + panel refresh buttons) -----
// One bounded, user-initiated device read: hits the device, updates the cache,
// returns the fresh data. Throws on unreachable/disabled.
async function forceDeviceRead(unitId) {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/live`);
if (!r.ok) {
let detail = 'device unreachable';
try { detail = (await r.json()).detail || detail; } catch (e) {}
throw new Error(detail);
}
return (await r.json()).data || {};
}
function spinIcon(el, on) {
if (el) el.classList.toggle('animate-spin', on);
}
function applyFreshReadToPanel(unitId, d) {
if (window.selectedUnitId !== unitId) return;
updateDashboardMetrics(d);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
// The read just happened, so "now" is the accurate freshness even if the
// /live payload doesn't echo last_seen.
setPanelStatus(measuring, d.last_seen || new Date().toISOString());
}
// Device-list row refresh button.
async function refreshSlmUnit(unitId, btn) {
const icon = btn ? btn.querySelector('svg') : null;
if (btn) btn.disabled = true;
spinIcon(icon, true);
try {
const d = await forceDeviceRead(unitId);
applyFreshReadToPanel(unitId, d);
// Reload the list so the row's badge + last-check reflect the new cache.
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
htmx.trigger('#slm-devices-list', 'load');
}
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
} catch (e) {
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
else console.warn('refresh failed', e);
} finally {
if (btn) btn.disabled = false;
spinIcon(icon, false);
}
}
// Panel header refresh button (refreshes the unit the panel is showing).
async function refreshDashboardPanel() {
const unitId = window.selectedUnitId;
if (!unitId) return;
const icon = document.getElementById('panel-refresh-icon');
spinIcon(icon, true);
try {
const d = await forceDeviceRead(unitId);
applyFreshReadToPanel(unitId, d);
updateDashboardChart(d); // append the fresh point to the chart
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
htmx.trigger('#slm-devices-list', 'load');
}
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
} catch (e) {
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
} finally {
spinIcon(icon, false);
}
}
// Configuration modal - use unified SLM settings modal // Configuration modal - use unified SLM settings modal
function openDeviceConfigModal(unitId) { function openDeviceConfigModal(unitId) {
// Call the unified modal function from slm_settings_modal.html // Call the unified modal function from slm_settings_modal.html
View File
-64
View File
@@ -1,64 +0,0 @@
"""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
-23
View File
@@ -1,23 +0,0 @@
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
-16
View File
@@ -1,16 +0,0 @@
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()
-40
View File
@@ -1,40 +0,0 @@
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}
-46
View File
@@ -1,46 +0,0 @@
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
-60
View File
@@ -1,60 +0,0 @@
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", "")
-29
View File
@@ -1,29 +0,0 @@
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")
-81
View File
@@ -1,81 +0,0 @@
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
-20
View File
@@ -1,20 +0,0 @@
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", "")