Merge pull request 'update to 0.16.0' (#72) from dev into main

Reviewed-on: #72
This commit was merged in pull request #72.
This commit is contained in:
2026-06-23 00:59:44 -04:00
43 changed files with 4954 additions and 195 deletions
+25
View File
@@ -0,0 +1,25 @@
# Terra-View deployment configuration — EXAMPLE / template.
#
# Copy this to `.env` in the same directory as docker-compose.yml and fill in
# real values: cp .env.example .env
# `.env` is gitignored — NEVER commit real secrets. Docker Compose auto-loads
# `.env` and substitutes these into the ${VAR} placeholders in docker-compose.yml.
# Cookie-signing secret shared by the client portal AND the operator-auth
# session cookie. MUST be a strong random value in production — the in-code
# fallback ("dev-insecure-change-me") is public and forgeable.
# Generate one (and keep it secret):
# python3 -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=change-me-generate-a-strong-random-value
# Set true ONLY when the app is served over HTTPS. On plain HTTP leave it false,
# or the browser won't send the session cookie and login will look broken.
COOKIE_SECURE=false
# Operator-auth login gate. Leave false to deploy "dark" (the app behaves exactly
# as before — nothing gated, nothing can lock you out). Roll out by: deploy with
# false -> seed a superadmin via `docker compose exec web-app python3
# backend/operator_admin.py create-superadmin ...` -> confirm you can log in ->
# set true and `docker compose up -d web-app` to enforce. Setting it back to
# false is the instant escape hatch.
OPERATOR_AUTH_ENABLED=false
+53
View File
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.16.0] - 2026-06-23
**Modular projects & live Overview** — the project page becomes a module-aware workspace. Sound and vibration are now first-class *modules* with independent lifecycles (finish the sound study while vibration keeps running), the internal Overview gains the live-monitoring treatment the client portal already had, and vibration events become browsable in-app. Rounds out a batch of project-page UX work plus deployment-hopper and filter fixes carried since 0.15.0.
### Added
- **Per-module status (independent lifecycle).** Each project module (Sound / Vibration) now carries its own status — `active` / `on_hold` / `completed` — separate from the parent project. Mark the sound side "Completed" while vibration stays "Active" instead of archiving the whole project. Set from a status control in each module's tab; surfaced as a badge on the header module chips and on the project cards. New `PUT /api/projects/{id}/modules/{type}/status`; new `project_modules.status` column.
- **Live monitoring on the internal project Overview.** The internal project page gets the live treatment the client portal already had: a live-monitoring section with per-NRL tiles (current level + status), live/offline counts, a "loudest now" readout, and auto-refresh — reading SLMM's shared cached feed, no extra device hits. Live status chips on the NRL list cards; tiles are clickable through to the NRL detail page. Shown only for projects with live-mode (connected) sound NRLs.
- **Vibration events, browsable in-app.** A project-wide **Events** sub-tab on the Vibration tab lists SFM events across every vibration location, with a **Location** filter and **sortable columns** (timestamp, location, serial, Tran/Vert/Long/PVS/Mic). Each Vibration location card shows its **last event**. Rows open the shared event-detail modal.
- **24-Hour session period type.** A full-day (day + night) period type for 24/7 jobs, alongside the existing weekday/weekend day/night types; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime.
- **Per-module project cards + quick-open.** Redesigned project cards: a module-mix accent strip, per-module stat lines (e.g. "Vibration · 4 locations · 2 units" / "Sound · 2 NRLs · 2 units · 0 recording"), and **Sound / Vibration quick-open buttons** that deep-link straight into that module's tab.
- **"Reforward info" button** on classified deployment-hopper cards — re-syncs the captured photo's GPS/metadata onto the location (recovery path for the coords-drop bug fixed below).
- **`redeploy.sh`** helper — rebuild + redeploy a single compose service with `--no-deps` instead of rebuilding the whole stack.
### Changed
- **Project page restructured around modules.** Sound-only actions (Generate Combined Report, Night Report, Report Settings) and the Manual/Remote chip moved out of the global project header into the **Sound tab's** toolbar; the header now carries project-level concerns only (status, modules, merge). The Overview's locations are split into **Vibration locations** and **NRLs** instead of one mixed list.
- **Project cards: clearer stats.** The ambiguous single Locations / Units / **Active** row (where "Active" collided with the status badge and only counted sound recording sessions) is replaced by per-module stat lines; the misleading single-module subtitle ("Sound Monitoring") is replaced by the project's identity (number · client). Unit counts are derived by the assigned location's type, so each module's unit count reconciles with its location count.
- **Overview live-monitoring scope.** The live section lists only connected / live-mode NRLs; offline / manual-upload NRLs are excluded, and the section is hidden entirely when no NRL is in a live mode.
### Fixed
- **SLM "Start measurement" showed a false "Unknown error"** even though the unit was actually starting — the proxy timed out on a slow handler and surfaced an empty error. (Pairs with the SLMM-side `'Start'`-state fix in SLMM v0.4.0.)
- **Empty project dropdown** in the pending-deployment classify modal — the projects endpoint returned HTML while the modal expected JSON; now backed by a JSON endpoint.
- **Classify button stuck on "Classifying…"** after reopening the modal post-deploy.
- **Deployment-capture GPS dropped** when assigning to an *existing* location — the captured coordinates now backfill onto a coordless existing location (and the new Reforward button re-syncs after the fact).
- **Event date filters were unusable** on the unit / vibration-location detail pages (datetime-local inputs with no apply) — replaced with date inputs that apply on change.
### Upgrade Notes
- **Migration:** run `backend/migrate_add_module_status.py` once against the production DB (adds `project_modules.status`, default `active`) — e.g. `docker compose exec web-app python3 backend/migrate_add_module_status.py`. Without it, the per-module status endpoints/UI error.
- **Ships with SLMM v0.4.0.** The Overview live monitoring reads SLMM's shared `/monitor` fan-out feed, and the SLM start fix pairs with SLMM's `'Start'`-state recognition — deploy the matching SLMM v0.4.0 build, not one without the other.
## [0.15.0] - 2026-06-18
**Operator authentication** — the internal app gets a login. The operator-facing surface had **zero auth**; this adds a deny-by-default login gate and roles, the prerequisite that makes the app safe to put behind a public URL (office-deployment sequencing: auth → expose). Built test-first (10 tasks, 90 passing tests — the project's first auth test suite alongside the portal's).
### Added
- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.** The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints `/emitters/report` `/api/series3/heartbeat` `/api/series4/heartbeat`). Two roles — `superadmin` (account management at `/admin/users`) and `admin` (full app); `operator` reserved/deferred. Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`). Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI. Brute-force lockout (5 tries / 15 min) + constant-time login (no user-enumeration). Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` HMAC signer. Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`, plan: `docs/superpowers/plans/2026-06-17-operator-auth.md`.
- **`.env.example`** documenting `SECRET_KEY` / `COOKIE_SECURE` / `OPERATOR_AUTH_ENABLED` for deployment.
### Known limitations
- **SLMM proxy WebSocket endpoints bypass the gate.** `/api/slmm/{id}/stream|live|monitor` are WebSocket upgrades, which a Starlette HTTP middleware never sees — they stay unauthenticated even with the gate on. Pre-existing (not a regression); close it (in-handler `tv_session` check, as the portal WS already does) before true internet exposure.
- **No TLS yet** — until served over HTTPS the login password crosses the wire in cleartext. Still a large improvement over the prior zero-auth exposure; real internet exposure needs the deployment-phase TLS.
### Upgrade Notes
- New `operator_users` table **auto-creates on startup — no migration**.
- Set a real `SECRET_KEY` (in a gitignored `.env`; template at `.env.example`) before internet exposure; set `COOKIE_SECURE=true` once on HTTPS (leave `false` on plain HTTP or the browser won't send the cookie).
- **Rollout (no self-lockout):** deploy with `OPERATOR_AUTH_ENABLED=false` (app behaves exactly as before) → seed a superadmin via `docker compose exec web-app python3 backend/operator_admin.py create-superadmin …` → confirm you can log in → set the flag `true` and `docker compose up -d web-app`. Flipping it back to `false` is the instant escape hatch.
## [0.14.0] - 2026-06-17 ## [0.14.0] - 2026-06-17
Rounds out **sound monitoring** and adds a **client-facing portal**, consolidating four threads since 0.13.x: SLM live monitoring (now on SLMM's shared, cached feed), an automated **FTP night-report pipeline**, a read-only **client portal**, and **per-project password auth** for it. Depends on the matching **SLMM `dev`** build — see Upgrade Notes at the end of each section. Rounds out **sound monitoring** and adds a **client-facing portal**, consolidating four threads since 0.13.x: SLM live monitoring (now on SLMM's shared, cached feed), an automated **FTP night-report pipeline**, a read-only **client portal**, and **per-project password auth** for it. Depends on the matching **SLMM `dev`** build — see Upgrade Notes at the end of each section.
+17 -3
View File
@@ -1,4 +1,4 @@
# Terra-View v0.14.0 # Terra-View v0.16.0
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs, sound level meters, and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs, sound level meters, and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features
@@ -504,6 +504,16 @@ docker compose down -v
## Release Highlights ## Release Highlights
### v0.16.0 — 2026-06-23
- **Per-Module Status**: Sound and Vibration are independent modules with their own lifecycle (`active` / `on_hold` / `completed`) — mark the sound study "Completed" while vibration keeps running, instead of archiving the whole project. Shown as badges on the header chips and project cards. (Migration: `backend/migrate_add_module_status.py`.)
- **Live Monitoring on the Internal Overview**: The project Overview gains per-NRL live tiles (current level + status), live/offline counts, a "loudest now" readout, and live status chips on the NRL cards — reading SLMM's shared cached feed (no extra device hits). Tiles deep-link to the NRL detail page.
- **Browsable Vibration Events**: A project-wide Events sub-tab on the Vibration tab with a Location filter and sortable columns (timestamp / location / serial / Tran / Vert / Long / PVS / Mic); each Vibration location card shows its last event.
- **24-Hour Session Period Type**: Full-day (day + night) period for 24/7 jobs; combined reports bucket a 24-Hour session's intervals into Daytime / Evening / Nighttime.
- **Redesigned Project Cards**: Module-mix accent strip, per-module stat lines (replacing the ambiguous Locations / Units / Active row), and Sound / Vibration quick-open buttons that jump straight into a module's tab.
- **Project page restructured around modules**: sound-only actions (Combined Report, Night Report, Report Settings) moved from the global header into the Sound tab; Overview locations split into Vibration locations and NRLs.
- **Fixes**: false "Unknown error" on SLM start, empty project dropdown + stuck button in the deployment classify modal, deployment GPS dropped when assigning to an existing location (+ Reforward button), unusable event date filters.
- **Ships with SLMM v0.4.0** (shared `/monitor` fan-out feed + `'Start'`-state fix).
### v0.11.0 — 2026-05-15 ### v0.11.0 — 2026-05-15
- **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page. - **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page.
- **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view. - **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view.
@@ -633,9 +643,13 @@ MIT
## Version ## Version
**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15) **Current: 0.16.0** — Modular projects & live Overview: per-module status (independent sound/vibration lifecycle), internal live-monitoring Overview, browsable vibration events, 24-Hour period type, redesigned project cards (2026-06-23)
Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14) Previous: 0.15.0 — Operator authentication: deny-by-default login gate + superadmin/admin roles, 30-day session cookie, `/admin/users` (2026-06-18)
0.11.0 — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06) 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
+64
View File
@@ -0,0 +1,64 @@
# backend/auth_cookies.py
"""Generic HMAC-signed cookie payloads, shared by operator auth (and, optionally
later, the portal). A signed value is f"{b64url(json)}.{hmac_sha256(b64)}"; read()
verifies the signature in constant time and enforces a server-side iat expiry.
The signing secret is the same SECRET_KEY the portal already reads, so a single
env var protects both cookies. Never store or log raw secrets."""
import os
import hmac
import json
import time
import base64
import hashlib
import logging
logger = logging.getLogger(__name__)
# Same env var the portal cookie uses — one secret protects both. The insecure
# default only exists so dev/test boots without config; set a real SECRET_KEY in prod.
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
# Set COOKIE_SECURE=true once served over HTTPS; leave false on plain HTTP or the
# browser won't send the cookie.
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
def _sign(body: str) -> str:
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
def sign(payload: dict) -> str:
"""Serialize + sign a payload dict into a cookie-safe string."""
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
return f"{body}.{_sign(body)}"
def read(raw, max_age: int):
"""Verify a signed value and return its payload dict, or None if missing,
tampered, or older than max_age seconds (by its own `iat`)."""
if not raw or not isinstance(raw, str):
return None
try:
body, sig = raw.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()))
except Exception:
return None
if not isinstance(data, dict):
return None
iat = data.get("iat")
if not isinstance(iat, (int, float)):
return None
now = time.time()
# Reject implausibly future-dated tokens: the same server signs and verifies,
# so there's no real clock skew — a far-future iat (e.g. to dodge max_age or
# outlive a sessions_valid_from bump) is bogus. 60s of slack is generous.
if iat - now > 60:
return None
if (now - iat) > max_age:
return None
return data
+13 -1
View File
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.14.0" VERSION = "0.16.0"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":
@@ -89,6 +89,18 @@ async def add_environment_to_context(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
return response return response
# Operator auth — deny-by-default gate over the whole internal app. Governed by
# OPERATOR_AUTH_ENABLED (default off → behaves exactly as today). See
# docs/superpowers/specs/2026-06-17-operator-auth-design.md.
from backend.operator_auth import operator_gate
app.middleware("http")(operator_gate)
from backend.routers import operator_auth_routes
app.include_router(operator_auth_routes.router)
from backend.routers import operator_users
app.include_router(operator_users.router)
# Override TemplateResponse to include environment and version in context # Override TemplateResponse to include environment and version in context
original_template_response = templates.TemplateResponse original_template_response = templates.TemplateResponse
def custom_template_response(name, context=None, *args, **kwargs): def custom_template_response(name, context=None, *args, **kwargs):
+54
View File
@@ -0,0 +1,54 @@
"""
Migration: add a per-module `status` column to `project_modules`.
A combined project (sound + vibration) often finishes one kind of work before
the other. Rather than archiving the whole project, each module now carries its
own lifecycle so e.g. the sound side can read "Completed" while vibration stays
"Active".
Behavior:
- status = 'active' module is live (default for all existing rows)
- status = 'on_hold' paused; data/tabs stay visible
- status = 'completed' wrapped up; surfaced as a done badge
Idempotent safe to re-run. Non-destructive adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_module_status.py
"""
import os
import sqlite3
DB_PATH = "./data/seismo_fleet.db"
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
cur.execute(f"PRAGMA table_info({table})")
return any(row[1] == column for row in cur.fetchall())
def migrate_database() -> None:
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
if not _has_column(cur, "project_modules", "status"):
cur.execute(
"ALTER TABLE project_modules ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
)
conn.commit()
print(" Added column project_modules.status (default 'active').")
else:
print(" project_modules already has status — nothing to do.")
conn.close()
if __name__ == "__main__":
print("Running migration: add status to project_modules")
migrate_database()
print("Done.")
+35
View File
@@ -3,6 +3,13 @@ from datetime import datetime
from backend.database import Base from backend.database import Base
def _utcnow_seconds():
"""utcnow truncated to whole seconds — used as the default for
sessions_valid_from so a freshly-issued cookie (whose iat is a whole-second
epoch) never falls a few microseconds before it and self-invalidates."""
return datetime.utcnow().replace(microsecond=0)
class Emitter(Base): class Emitter(Base):
__tablename__ = "emitters" __tablename__ = "emitters"
@@ -218,6 +225,10 @@ class ProjectModule(Base):
project_id = Column(String, nullable=False, index=True) # FK to projects.id project_id = Column(String, nullable=False, index=True) # FK to projects.id
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ... module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
enabled = Column(Boolean, default=True, nullable=False) enabled = Column(Boolean, default=True, nullable=False)
# Per-module lifecycle, independent of the parent project's status. Lets one
# part of a combined project wrap up (e.g. sound "completed") while another
# keeps running ("active"). Values: active | on_hold | completed.
status = Column(String, default="active", nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),) __table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
@@ -772,3 +783,27 @@ class ClientAccessToken(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
last_used_at = Column(DateTime, nullable=True) last_used_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True) # set = link no longer works revoked_at = Column(DateTime, nullable=True) # set = link no longer works
# ============================================================================
# OPERATOR AUTH — internal operator logins (see backend/operator_auth.py)
# ============================================================================
class OperatorUser(Base):
"""An internal operator login. Roles: 'superadmin' (Brian, + account mgmt) and
'admin' (parents, full app). 'operator' is reserved (deferred). Brand-new table
create_all builds it, no migration. Never store or log raw passwords."""
__tablename__ = "operator_users"
id = Column(String, primary_key=True, index=True) # UUID
email = Column(String, nullable=False, unique=True, index=True) # login handle, lowercased
display_name = Column(String, nullable=False) # "Brian", "Dad"
password_hash = Column(String, nullable=False) # argon2id
role = Column(String, nullable=False, default="admin") # superadmin | admin
active = Column(Boolean, default=True) # False = login disabled
must_change_password = Column(Boolean, default=False) # forces a change next login
sessions_valid_from = Column(DateTime, default=_utcnow_seconds) # bump = log out everywhere
failed_login_count = Column(Integer, default=0) # lockout counter
locked_until = Column(DateTime, nullable=True) # set after too many bad tries
created_at = Column(DateTime, default=datetime.utcnow)
last_login_at = Column(DateTime, nullable=True)
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Operator-account admin CLI — the bootstrap and the break-glass. Run inside the
terra-view container against the live DB. Temp/raw passwords are printed ONCE; only
hashes persist.
# first superadmin (before any UI is reachable) — prompts for a password, or --generate
python3 backend/operator_admin.py create-superadmin --email you@x.com --name "Brian"
# a parent's account — generates a temp password, must-change on first login
python3 backend/operator_admin.py create-user --email dad@x.com --name "Dad" --role admin
python3 backend/operator_admin.py reset-password --email dad@x.com
python3 backend/operator_admin.py list
python3 backend/operator_admin.py disable --email dad@x.com
python3 backend/operator_admin.py enable --email dad@x.com
"""
import os
import sys
import getpass
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import SessionLocal
from backend.models import OperatorUser
from backend.operator_auth import (
create_operator, reset_operator_password, set_operator_active, _norm_email,
)
def _get(db, email):
u = db.query(OperatorUser).filter_by(email=_norm_email(email)).first()
if not u:
sys.exit(f"No operator with email '{email}'.")
return u
def cmd_create_superadmin(email, name, password=None, generate=False):
db = SessionLocal()
try:
if password is None and not generate:
password = getpass.getpass("Password for new superadmin: ")
if not password or len(password) < 8:
sys.exit("Password must be at least 8 characters.")
user, raw = create_operator(db, email, name, "superadmin",
password=None if generate else password)
if generate:
print(f"✓ Superadmin {user.email} created. Temp password (shown once): {raw}")
else:
print(f"✓ Superadmin {user.email} created.")
except ValueError as e:
sys.exit(str(e))
finally:
db.close()
def cmd_create_user(email, name, role="admin"):
db = SessionLocal()
try:
user, raw = create_operator(db, email, name, role)
print(f"{role} {user.email} created. Temp password (shown once): {raw}")
print(" They'll be required to change it on first login.")
except ValueError as e:
sys.exit(str(e))
finally:
db.close()
def cmd_reset_password(email):
db = SessionLocal()
try:
user = _get(db, email)
raw = reset_operator_password(db, user)
print(f"✓ Reset {user.email}. Temp password (shown once): {raw}")
finally:
db.close()
def cmd_set_active(email, active):
db = SessionLocal()
try:
user = _get(db, email)
set_operator_active(db, user, active)
print(f"{user.email} {'enabled' if active else 'disabled'}.")
finally:
db.close()
def cmd_list():
db = SessionLocal()
try:
users = db.query(OperatorUser).order_by(OperatorUser.display_name).all()
if not users:
print("No operators yet. Run create-superadmin first.")
return
for u in users:
locked = " [LOCKED]" if (u.locked_until and u.locked_until > datetime.utcnow()) else ""
state = "active" if u.active else "DISABLED"
last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never"
print(f" {u.display_name:<12} {u.email:<28} {u.role:<11} {state}{locked} last: {last}")
finally:
db.close()
def main():
ap = argparse.ArgumentParser(description="Operator-account admin")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("create-superadmin")
p.add_argument("--email", required=True); p.add_argument("--name", required=True)
p.add_argument("--generate", action="store_true", help="generate a temp password instead of prompting")
p.set_defaults(fn=lambda a: cmd_create_superadmin(a.email, a.name, generate=a.generate))
p = sub.add_parser("create-user")
p.add_argument("--email", required=True); p.add_argument("--name", required=True)
p.add_argument("--role", default="admin", choices=["admin", "superadmin"])
p.set_defaults(fn=lambda a: cmd_create_user(a.email, a.name, a.role))
p = sub.add_parser("reset-password")
p.add_argument("--email", required=True)
p.set_defaults(fn=lambda a: cmd_reset_password(a.email))
p = sub.add_parser("disable"); p.add_argument("--email", required=True)
p.set_defaults(fn=lambda a: cmd_set_active(a.email, False))
p = sub.add_parser("enable"); p.add_argument("--email", required=True)
p.set_defaults(fn=lambda a: cmd_set_active(a.email, True))
p = sub.add_parser("list"); p.set_defaults(fn=lambda a: cmd_list())
args = ap.parse_args()
args.fn(args)
if __name__ == "__main__":
main()
+231
View File
@@ -0,0 +1,231 @@
# backend/operator_auth.py
"""Operator authentication: the deny-by-default gate, session cookie, login +
lockout, and the small data helpers shared by the routes and the CLI. Reuses the
argon2 hasher (auth_passwords) and the HMAC signer (auth_cookies).
The flag and SessionLocal are read as module globals at call time so tests can
monkeypatch them."""
import os
import time
import uuid
from datetime import datetime, timedelta
from urllib.parse import quote
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse
from backend.models import OperatorUser
from backend.auth_passwords import hash_password, verify_password, generate_password
from backend.auth_cookies import sign, read, COOKIE_SECURE
from backend.database import SessionLocal
# Feature flag — OFF by default. When off, the gate and require_role both pass
# everything through and the app behaves exactly as it does today.
OPERATOR_AUTH_ENABLED = os.getenv("OPERATOR_AUTH_ENABLED", "false").lower() in ("1", "true", "yes")
COOKIE_NAME = "tv_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days ("remember this device")
MAX_LOGIN_FAILURES = 5
LOCK_MINUTES = 15
# Role ladder — a rank map so checks read naturally and 'operator' slots in later.
_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30}
# A throwaway hash used only to equalize verify time on the unknown-email path,
# so a missing account can't be distinguished from a wrong password by timing
# (no user-enumeration). The value never authenticates anything.
_DUMMY_PASSWORD_HASH = hash_password("operator-auth-timing-equalizer")
def role_at_least(role: str, minimum: str) -> bool:
"""True iff `role` ranks at or above `minimum`. Unknown roles rank as 0."""
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum]
def _norm_email(email: str) -> str:
return (email or "").strip().lower()
def make_operator_cookie(uid: str, iat: int = None) -> str:
"""Sign a tv_session value for a user id. iat defaults to now; pass an explicit
iat when you bump sessions_valid_from to that same instant (change-password)."""
return sign({"uid": uid, "iat": int(iat if iat is not None else time.time())})
def current_operator(request, db):
"""Resolve the OperatorUser for a request's tv_session cookie, or None.
Re-validated against the DB every call: a disabled / locked / password-changed
user drops on the next request. Used by the gate middleware (with its own
session) does not raise."""
data = read(request.cookies.get(COOKIE_NAME), COOKIE_MAX_AGE)
if not data:
return None
uid, iat = data.get("uid"), data.get("iat")
if not uid or not isinstance(iat, (int, float)):
return None
user = db.query(OperatorUser).filter_by(id=uid).first()
if not user or not user.active:
return None
if user.locked_until and user.locked_until > datetime.utcnow():
return None
if user.sessions_valid_from and datetime.utcfromtimestamp(int(iat)) < user.sessions_valid_from:
return None
return user
def register_login_failure(db, user) -> None:
"""Increment a user's failure counter and lock them out past the threshold."""
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= MAX_LOGIN_FAILURES:
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCK_MINUTES)
db.commit()
def authenticate(db, email, password):
"""Return (user, "ok") on success, (None, "locked") if locked out, else
(None, "bad"). Never reveals whether the email exists: an unknown email runs
the same argon2 verify (against a dummy hash) as a wrong password, so neither
the response text nor its timing distinguishes the two."""
user = db.query(OperatorUser).filter_by(email=_norm_email(email)).first()
if user and user.locked_until and user.locked_until > datetime.utcnow():
return None, "locked"
password_ok = verify_password(password, user.password_hash if user else _DUMMY_PASSWORD_HASH)
if not user or not user.active or not password_ok:
if user:
register_login_failure(db, user)
return None, "bad"
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.utcnow()
db.commit()
return user, "ok"
def create_operator(db, email, name, role, password=None, must_change=None):
"""Create an operator. With no password, generate a temp one and force a change
(must_change defaults True). With a password, must_change defaults False.
Returns (user, raw_password_to_show_once). Raises ValueError on dup/bad role."""
email = _norm_email(email)
if role not in _ROLE_RANK:
raise ValueError(f"unknown role {role!r}")
if db.query(OperatorUser).filter_by(email=email).first():
raise ValueError(f"operator {email} already exists")
if password is None:
password = generate_password()
if must_change is None:
must_change = True
elif must_change is None:
must_change = False
user = OperatorUser(id=str(uuid.uuid4()), email=email, display_name=name,
password_hash=hash_password(password), role=role,
active=True, must_change_password=must_change)
db.add(user)
db.commit()
return user, password
def reset_operator_password(db, user) -> str:
"""Generate a fresh temp password, force a change, log the user out everywhere.
Returns the raw password to show once."""
raw = generate_password()
user.password_hash = hash_password(raw)
user.must_change_password = True
user.failed_login_count = 0
user.locked_until = None
user.sessions_valid_from = datetime.utcnow().replace(microsecond=0)
db.commit()
return raw
def change_own_password(db, user, new_password) -> int:
"""Set a user's own new password, clear the forced-change flag, and bump
sessions_valid_from to the returned iat the caller mints the replacement
cookie with that exact iat so it stays valid while older cookies die."""
new_iat = int(time.time())
user.password_hash = hash_password(new_password)
user.must_change_password = False
user.sessions_valid_from = datetime.utcfromtimestamp(new_iat)
db.commit()
return new_iat
def set_operator_active(db, user, active: bool):
user.active = bool(active)
db.commit()
return user
def set_operator_role(db, user, role: str):
if role not in _ROLE_RANK:
raise ValueError(f"unknown role {role!r}")
user.role = role
db.commit()
return user
# Routes reachable with no login. A new route added next year is gated by default.
_EXEMPT_EXACT = {
"/login", "/logout", "/health",
"/manifest.json", "/sw.js", "/favicon.ico", "/offline-db.js",
"/portal", # portal home (its own auth)
# machine endpoints — LAN-only, automated, no human (watchers/heartbeats):
"/emitters/report", "/api/series3/heartbeat", "/api/series4/heartbeat",
}
_EXEMPT_PREFIX = ("/static/", "/portal/")
def _is_exempt(path: str) -> bool:
return path in _EXEMPT_EXACT or path.startswith(_EXEMPT_PREFIX)
async def operator_gate(request: Request, call_next):
"""Deny-by-default gate. Flag off → pass through (app as today). Flag on →
exempt paths pass; otherwise require a valid operator session, stash it on
request.state.operator, and force a password change when pending."""
if not OPERATOR_AUTH_ENABLED:
return await call_next(request)
# CORS preflight carries no auth and must reach CORSMiddleware, not the gate.
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
if _is_exempt(path):
return await call_next(request)
db = SessionLocal()
try:
user = current_operator(request, db)
if user is not None:
db.expunge(user) # detach a fully-loaded row so we can close now
finally:
db.close()
if user is None:
if path.startswith("/api/"):
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return RedirectResponse(f"/login?next={quote(path)}", status_code=303)
if user.must_change_password and path not in ("/change-password", "/logout"):
if path.startswith("/api/"):
return JSONResponse({"detail": "Password change required"}, status_code=403)
return RedirectResponse("/change-password", status_code=303)
request.state.operator = user
return await call_next(request)
def require_role(minimum: str):
"""Dependency factory: require a logged-in operator ranked >= `minimum`.
Respects the flag (off pass through). When on, the middleware has already
set request.state.operator before this runs."""
def _dep(request: Request):
if not OPERATOR_AUTH_ENABLED:
return None
user = getattr(request.state, "operator", None)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
if not role_at_least(user.role, minimum):
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
return _dep
+111
View File
@@ -0,0 +1,111 @@
"""Operator login / logout / change-password. These routes intentionally work
regardless of OPERATOR_AUTH_ENABLED (you log in while the flag is still off during
rollout). /login and /logout are on the gate's exempt list; /change-password
requires a session (the gate sets request.state.operator)."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import OperatorUser
from backend.templates_config import templates
from backend.operator_auth import (
authenticate, current_operator, change_own_password, make_operator_cookie,
COOKIE_NAME, COOKIE_MAX_AGE,
)
from backend.auth_cookies import COOKIE_SECURE
from backend.auth_passwords import verify_password
router = APIRouter(tags=["operator-auth"])
def _safe_next(next_url: str) -> str:
"""Only allow same-site relative redirects (an open-redirect guard). Rejects
`//host` and `/\\host` browsers treat a backslash as `/` in the authority
position, so both escape to an external site."""
if next_url and next_url.startswith("/") and not next_url.startswith(("//", "/\\")):
return next_url
return "/"
@router.get("/login")
async def login_page(request: Request, next: str = "", error: str = ""):
return templates.TemplateResponse("login.html",
{"request": request, "next": next, "error": error})
@router.post("/login")
async def login_submit(request: Request, next: str = "",
email: str = Form(...), password: str = Form(...),
db: Session = Depends(get_db)):
user, status = authenticate(db, email, password)
if status == "locked":
return templates.TemplateResponse(
"login.html",
{"request": request, "next": next,
"error": "Too many attempts — try again in 15 minutes."},
status_code=200)
if user is None:
return templates.TemplateResponse(
"login.html",
{"request": request, "next": next, "error": "Invalid email or password."},
status_code=200)
dest = "/change-password" if user.must_change_password else _safe_next(next)
resp = RedirectResponse(url=dest, status_code=303)
resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
return resp
@router.get("/logout")
async def logout(request: Request):
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(COOKIE_NAME)
return resp
@router.get("/change-password")
async def change_password_page(request: Request, db: Session = Depends(get_db)):
user = getattr(request.state, "operator", None) or current_operator(request, db)
if user is None:
return RedirectResponse(url="/login", status_code=303)
return templates.TemplateResponse(
"change_password.html",
{"request": request, "must_change": user.must_change_password, "error": ""})
@router.post("/change-password")
async def change_password_submit(request: Request,
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
db: Session = Depends(get_db)):
_user_ref = getattr(request.state, "operator", None) or current_operator(request, db)
if _user_ref is None:
return RedirectResponse(url="/login", status_code=303)
# Re-fetch a session-bound copy so mutations via `db` will be committed.
# request.state.operator may be expunged (detached) from the gate's own
# SessionLocal; operating on a detached object against a different session
# would silently drop the UPDATE.
user = db.query(OperatorUser).filter_by(id=_user_ref.id).first()
if user is None:
return RedirectResponse(url="/login", status_code=303)
def _err(msg):
return templates.TemplateResponse(
"change_password.html",
{"request": request, "must_change": user.must_change_password, "error": msg},
status_code=200)
if not verify_password(current_password, user.password_hash):
return _err("Current password is incorrect.")
if len(new_password) < 8:
return _err("New password must be at least 8 characters.")
if new_password != confirm_password:
return _err("New passwords do not match.")
new_iat = change_own_password(db, user, new_password)
resp = RedirectResponse(url="/", status_code=303)
resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id, iat=new_iat),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
return resp
+115
View File
@@ -0,0 +1,115 @@
"""Operator account management — superadmin only. Temp passwords are returned in
the JSON response once (shown to the superadmin to hand off); only hashes persist."""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.templates_config import templates
from backend.models import OperatorUser
from backend.operator_auth import (
require_role, create_operator, reset_operator_password,
set_operator_active, set_operator_role,
)
import backend.operator_auth as operator_auth
from backend.utils.timezone import format_local_datetime
def _require_auth_enabled():
"""The operator-management surface does not exist while operator auth is
disabled otherwise these net-new endpoints would be world-open with the
flag off (the default), letting anyone pre-seed a superadmin. Read the flag
as a live module attribute so the test monkeypatch and a runtime flip both
take effect."""
if not operator_auth.OPERATOR_AUTH_ENABLED:
raise HTTPException(status_code=404, detail="Not found")
router = APIRouter(tags=["operator-users"], dependencies=[Depends(_require_auth_enabled)])
_superadmin = require_role("superadmin")
class NewUser(BaseModel):
email: str
name: str
role: str = "admin"
class RoleChange(BaseModel):
role: str
def _serialize(u: OperatorUser) -> dict:
from datetime import datetime
return {
"id": u.id, "email": u.email, "display_name": u.display_name, "role": u.role,
"active": bool(u.active), "must_change_password": bool(u.must_change_password),
"locked": bool(u.locked_until and u.locked_until > datetime.utcnow()),
"last_login_at": format_local_datetime(u.last_login_at, "%Y-%m-%d %H:%M") if u.last_login_at else None,
}
@router.get("/admin/users")
async def users_page(request: Request, _=Depends(_superadmin)):
return templates.TemplateResponse("admin/users.html", {"request": request})
@router.get("/api/admin/users")
async def list_users(_=Depends(_superadmin), db: Session = Depends(get_db)):
users = db.query(OperatorUser).order_by(OperatorUser.display_name).all()
return {"users": [_serialize(u) for u in users]}
@router.post("/api/admin/users")
async def add_user(body: NewUser, _=Depends(_superadmin), db: Session = Depends(get_db)):
if body.role not in ("admin", "superadmin"):
return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"})
try:
user, raw = create_operator(db, body.email, body.name, body.role)
except ValueError as e:
return JSONResponse(status_code=400, content={"detail": str(e)})
return {"user": _serialize(user), "password": raw}
@router.post("/api/admin/users/{user_id}/reset-password")
async def reset_user_password(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)):
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
raw = reset_operator_password(db, user)
return {"password": raw}
@router.post("/api/admin/users/{user_id}/disable")
async def disable_user(user_id: str, acting=Depends(_superadmin), db: Session = Depends(get_db)):
if acting and acting.id == user_id:
return JSONResponse(status_code=400, content={"detail": "Cannot disable your own account"})
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_operator_active(db, user, False)
return {"active": False}
@router.post("/api/admin/users/{user_id}/enable")
async def enable_user(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)):
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_operator_active(db, user, True)
return {"active": True}
@router.post("/api/admin/users/{user_id}/role")
async def change_user_role(user_id: str, body: RoleChange,
acting=Depends(_superadmin), db: Session = Depends(get_db)):
if acting and acting.id == user_id:
return JSONResponse(status_code=400, content={"detail": "Cannot change your own role"})
if body.role not in ("admin", "superadmin"):
return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"})
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_operator_role(db, user, body.role)
return {"role": user.role}
+50
View File
@@ -296,6 +296,12 @@ async def promote_pending(
if not location: if not location:
raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.") raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.")
project_id = location.project_id project_id = location.project_id
# Backfill the captured GPS onto the existing location if it doesn't
# have coordinates yet. (Previously the captured coords were dropped on
# the assign-to-existing path, so only create-new locations got a pin.)
# Don't clobber coordinates an operator already set.
if pd.coordinates and not (location.coordinates or "").strip():
location.coordinates = pd.coordinates
else: else:
# Create-new path. Need a project (existing or new). # Create-new path. Need a project (existing or new).
project_id = payload.get("project_id") project_id = payload.get("project_id")
@@ -456,6 +462,50 @@ async def cancel_pending(
return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()} return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()}
@router.post("/pending/{pending_id}/resync-location")
async def resync_location(pending_id: str, db: Session = Depends(get_db)):
"""Re-push a promoted capture's GPS onto its assigned location.
Use when a capture's coordinates didn't land on the location (e.g. it was
assigned to a pre-existing location that had none). Unlike the auto-backfill
on classify, this is an explicit operator action and OVERWRITES the
location's coordinates with the captured GPS.
"""
pd = db.query(PendingDeployment).filter_by(id=pending_id).first()
if not pd:
raise HTTPException(status_code=404, detail="Pending deployment not found.")
if pd.status != "assigned" or not pd.resulting_assignment_id:
raise HTTPException(status_code=400, detail="Only a promoted (assigned) capture can be re-forwarded.")
if not (pd.coordinates or "").strip():
raise HTTPException(status_code=400, detail="This capture has no GPS coordinates to forward.")
asg = db.query(UnitAssignment).filter_by(id=pd.resulting_assignment_id).first()
if not asg:
raise HTTPException(status_code=404, detail="Resulting assignment not found.")
location = db.query(MonitoringLocation).filter_by(id=asg.location_id).first()
if not location:
raise HTTPException(status_code=404, detail="Assigned location not found.")
old = location.coordinates
location.coordinates = pd.coordinates.strip()
_record_history(
db, unit_id=pd.unit_id,
change_type="deployment_coords_reforwarded",
old_value=old,
new_value=location.coordinates,
notes=f"Re-forwarded capture GPS to location '{location.name}'",
)
db.commit()
return {
"success": True,
"location_id": location.id,
"location_name": location.name,
"coordinates": location.coordinates,
}
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict: def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict:
+71 -1
View File
@@ -160,6 +160,7 @@ async def get_project_locations(
# (sessions don't really exist for the watcher-forward pipeline). # (sessions don't really exist for the watcher-forward pipeline).
# Sound locations skip this and keep showing session counts. # Sound locations skip this and keep showing session counts.
event_counts: dict[str, int] = {} event_counts: dict[str, int] = {}
last_events: dict[str, str] = {}
vibration_locations = [l for l in locations if l.location_type == "vibration"] vibration_locations = [l for l in locations if l.location_type == "vibration"]
if vibration_locations: if vibration_locations:
import asyncio import asyncio
@@ -171,7 +172,10 @@ async def get_project_locations(
for loc, res in zip(vibration_locations, results): for loc, res in zip(vibration_locations, results):
if isinstance(res, Exception): if isinstance(res, Exception):
continue # leave event_counts[loc.id] unset → template falls back continue # leave event_counts[loc.id] unset → template falls back
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0 stats = res.get("stats") or {}
event_counts[loc.id] = stats.get("event_count", 0) or 0
if stats.get("last_event"):
last_events[loc.id] = stats["last_event"]
# Enrich with assignment info, splitting active vs removed. # Enrich with assignment info, splitting active vs removed.
active_data: list = [] active_data: list = []
@@ -205,6 +209,8 @@ async def get_project_locations(
} }
if location.id in event_counts: if location.id in event_counts:
item["event_count"] = event_counts[location.id] item["event_count"] = event_counts[location.id]
if location.id in last_events:
item["last_event"] = last_events[location.id]
if location.removed_at is None: if location.removed_at is None:
active_data.append(item) active_data.append(item)
else: else:
@@ -1579,6 +1585,70 @@ async def get_project_vibration_summary(
) )
@router.get("/vibration-events", response_class=JSONResponse)
async def get_project_vibration_events(
project_id: str,
from_dt: Optional[datetime] = Query(None),
to_dt: Optional[datetime] = Query(None),
false_trigger: Optional[bool] = Query(None),
limit: int = Query(500, ge=1, le=5000),
db: Session = Depends(get_db),
):
"""Project-wide SFM events across every active vibration location.
Fans out events_for_location per location (each of which unions that
location's assignment windows), tags each event with its location, then
merges newest-first. Powers the Vibration tab's Events sub-tab.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
locations = (
db.query(MonitoringLocation)
.filter(
MonitoringLocation.project_id == project_id,
MonitoringLocation.location_type == "vibration",
MonitoringLocation.removed_at.is_(None),
)
.all()
)
if not locations:
return {"events": [], "count": 0, "location_count": 0}
import asyncio
from backend.services.sfm_events import events_for_location
results = await asyncio.gather(
*(
events_for_location(
db, loc.id, from_dt=from_dt, to_dt=to_dt,
false_trigger=false_trigger, limit=limit,
)
for loc in locations
),
return_exceptions=True,
)
merged = []
for loc, res in zip(locations, results):
if isinstance(res, Exception):
continue
for ev in res.get("events", []):
ev = dict(ev)
ev["location_id"] = loc.id
ev["location_name"] = loc.name
merged.append(ev)
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
total = len(merged)
return {
"events": merged[:limit],
"count": total,
"location_count": len(locations),
}
@router.get("/locations/{location_id}/events", response_class=JSONResponse) @router.get("/locations/{location_id}/events", response_class=JSONResponse)
async def get_location_events( async def get_location_events(
project_id: str, project_id: str,
+249 -14
View File
@@ -58,12 +58,25 @@ MODULES = {
} }
MODULE_STATUSES = {"active", "on_hold", "completed"}
def _get_project_modules(project_id: str, db: Session) -> list[str]: def _get_project_modules(project_id: str, db: Session) -> list[str]:
"""Return list of enabled module_type strings for a project.""" """Return list of enabled module_type strings for a project."""
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all() rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
return [r.module_type for r in rows] return [r.module_type for r in rows]
def _get_module_statuses(project_id: str, db: Session) -> dict[str, str]:
"""Return {module_type: status} for a project's enabled modules.
Per-module lifecycle is independent of the parent project's status — lets
one half of a combined project be "completed" while the other stays "active".
"""
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
return {r.module_type: (r.status or "active") for r in rows}
def _require_module(project: Project, module_type: str, db: Session) -> None: def _require_module(project: Project, module_type: str, db: Session) -> None:
"""Raise 400 if the project does not have the given module enabled.""" """Raise 400 if the project does not have the given module enabled."""
if not project: if not project:
@@ -404,6 +417,26 @@ def _build_combined_location_data(
# Project List & Overview # Project List & Overview
# ============================================================================ # ============================================================================
@router.get("/list-json")
async def get_projects_list_json(db: Session = Depends(get_db)):
"""JSON list of assignable projects (id, name, status) for pickers such as
the pending-deployment classify modal. Excludes deleted/archived/completed,
matching the default /list view. (The /list endpoint returns HTML cards, so
JSON consumers must use this one.)"""
projects = (
db.query(Project)
.filter(Project.status.notin_(["deleted", "archived", "completed"]))
.order_by(Project.name)
.all()
)
return JSONResponse({
"projects": [
{"id": p.id, "name": p.name, "status": p.status}
for p in projects
]
})
@router.get("/list", response_class=HTMLResponse) @router.get("/list", response_class=HTMLResponse)
async def get_projects_list( async def get_projects_list(
request: Request, request: Request,
@@ -437,8 +470,10 @@ async def get_projects_list(
for project in projects: for project in projects:
# Get project type # Get project type
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
mods = _get_project_modules(project.id, db)
# Count locations # Count locations (project-wide, includes removed — kept for back-compat
# with the compact list view).
location_count = db.query(func.count(MonitoringLocation.id)).filter_by( location_count = db.query(func.count(MonitoringLocation.id)).filter_by(
project_id=project.id project_id=project.id
).scalar() ).scalar()
@@ -451,7 +486,7 @@ async def get_projects_list(
) )
).scalar() ).scalar()
# Count active sessions # Count active (recording) sessions — a sound-monitoring concept
active_session_count = db.query(func.count(MonitoringSession.id)).filter( active_session_count = db.query(func.count(MonitoringSession.id)).filter(
and_( and_(
MonitoringSession.project_id == project.id, MonitoringSession.project_id == project.id,
@@ -459,9 +494,46 @@ async def get_projects_list(
) )
).scalar() ).scalar()
# Per-module stats — each module shows only its own, relevant counts.
# Locations: active only (removed_at IS NULL). Units: active assignments
# counted by the TYPE OF LOCATION they sit on (join), so a module's unit
# count always lines up with its own location count — independent of the
# assignment's denormalized device_type.
def _module_loc_count(loc_type):
return db.query(func.count(MonitoringLocation.id)).filter(
MonitoringLocation.project_id == project.id,
MonitoringLocation.location_type == loc_type,
MonitoringLocation.removed_at.is_(None),
).scalar()
def _module_unit_count(loc_type):
return db.query(func.count(UnitAssignment.id)).join(
MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id
).filter(
UnitAssignment.project_id == project.id,
UnitAssignment.assigned_until.is_(None),
MonitoringLocation.location_type == loc_type,
).scalar()
module_stats = {}
if "vibration_monitoring" in mods:
module_stats["vibration"] = {
"locations": _module_loc_count("vibration"),
"units": _module_unit_count("vibration"),
}
if "sound_monitoring" in mods:
module_stats["sound"] = {
"locations": _module_loc_count("sound"),
"units": _module_unit_count("sound"),
"recording": active_session_count,
}
projects_data.append({ projects_data.append({
"project": project, "project": project,
"project_type": project_type, "project_type": project_type,
"modules": mods,
"module_status": _get_module_statuses(project.id, db),
"module_stats": module_stats,
"location_count": location_count, "location_count": location_count,
"unit_count": unit_count, "unit_count": unit_count,
"active_session_count": active_session_count, "active_session_count": active_session_count,
@@ -818,6 +890,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
"project_type_id": project.project_type_id, "project_type_id": project.project_type_id,
"project_type_name": project_type.name if project_type else None, "project_type_name": project_type.name if project_type else None,
"modules": modules, "modules": modules,
"module_status": _get_module_statuses(project.id, db),
"status": project.status, "status": project.status,
"client_name": project.client_name, "client_name": project.client_name,
"site_address": project.site_address, "site_address": project.site_address,
@@ -882,6 +955,33 @@ async def remove_project_module(project_id: str, module_type: str, db: Session =
return {"ok": True, "modules": _get_project_modules(project_id, db)} return {"ok": True, "modules": _get_project_modules(project_id, db)}
@router.put("/{project_id}/modules/{module_type}/status")
async def set_project_module_status(
project_id: str, module_type: str, request: Request, db: Session = Depends(get_db)
):
"""Set a module's lifecycle status. Body: {status: active|on_hold|completed}.
Independent of the parent project's status — used to wrap up one half of a
combined project (e.g. sound "completed") while the other stays "active".
"""
data = await request.json()
status = (data.get("status") or "").strip()
if status not in MODULE_STATUSES:
raise HTTPException(
status_code=400,
detail=f"Invalid status '{status}'. Expected one of: {', '.join(sorted(MODULE_STATUSES))}.",
)
row = db.query(ProjectModule).filter_by(
project_id=project_id, module_type=module_type, enabled=True
).first()
if not row:
raise HTTPException(status_code=404, detail="Module not enabled on this project.")
row.status = status
db.commit()
return {"ok": True, "module_type": module_type, "status": status,
"module_status": _get_module_statuses(project_id, db)}
@router.put("/{project_id}") @router.put("/{project_id}")
async def update_project( async def update_project(
project_id: str, project_id: str,
@@ -1103,6 +1203,113 @@ async def get_project_dashboard(
}) })
@router.get("/{project_id}/live-stats")
async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)):
"""Live SLM readings for each sound NRL in the project.
Reads SLMM's cached per-unit status snapshots (the same source the client
portal uses) and returns one entry per active sound location. Powers the
Overview tab's live monitoring section. Internal-only, so it includes
device-health fields (battery, power source, reachability) the portal hides.
"""
import os
import asyncio
import httpx
import json as _json
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
now = datetime.utcnow()
locations = (
db.query(MonitoringLocation)
.filter(
MonitoringLocation.project_id == project_id,
MonitoringLocation.location_type == "sound",
MonitoringLocation.removed_at.is_(None),
)
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name)
.all()
)
# Only connected/live-mode NRLs belong in live monitoring. connection_mode
# lives in location_metadata JSON (default "connected"); offline/manual NRLs
# are excluded. With none connected, the caller gets [] and hides the section.
def _is_connected(loc) -> bool:
try:
meta = _json.loads(loc.location_metadata or "{}")
return meta.get("connection_mode", "connected") != "offline"
except Exception:
return True
locations = [loc for loc in locations if _is_connected(loc)]
# Active SLM unit per location (mirrors portal.active_unit_for_location).
def _active_unit(loc_id: str):
asg = (
db.query(UnitAssignment)
.filter(
UnitAssignment.location_id == loc_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
loc_units = [(loc, _active_unit(loc.id)) for loc in locations]
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
async def _fetch(unit_id):
if not unit_id:
return None, "no_device"
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{slmm_base}/api/nl43/{unit_id}/status")
except Exception:
return None, "unreachable"
if r.status_code != 200:
return None, "no_data"
return (r.json() or {}).get("data") or {}, None
results = await asyncio.gather(*[_fetch(u) for (_, u) in loc_units])
out = []
for (loc, unit_id), (data, reason) in zip(loc_units, results):
entry = {
"id": loc.id,
"name": loc.name,
"unit_id": unit_id,
}
if data is None:
entry["reason"] = reason
entry["measurement_state"] = None
else:
entry.update(
{
"measurement_state": data.get("measurement_state"),
"leq": data.get("leq"),
"lp": data.get("lp"),
"lmax": data.get("lmax"),
"last_seen": data.get("last_seen"),
"battery_level": data.get("battery_level"),
"power_source": data.get("power_source"),
"is_reachable": data.get("is_reachable"),
"connection_state": data.get("connection_state"),
}
)
out.append(entry)
return {"status": "ok", "locations": out}
# ============================================================================ # ============================================================================
# Project Types # Project Types
# ============================================================================ # ============================================================================
@@ -1128,6 +1335,7 @@ async def get_project_header(
"project": project, "project": project,
"project_type": project_type, "project_type": project_type,
"modules": _get_project_modules(project_id, db), "modules": _get_project_modules(project_id, db),
"module_status": _get_module_statuses(project_id, db),
}) })
@@ -2147,7 +2355,10 @@ async def delete_session(
}) })
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"} # full_24h = a single continuous 24-hour period (day + night). Operator-set
# only; never auto-derived. In reports its rows are split across
# Daytime/Evening/Nighttime by hour rather than filtered to one window.
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night", "full_24h"}
def _derive_period_type(dt: datetime) -> str: def _derive_period_type(dt: datetime) -> str:
@@ -2161,7 +2372,7 @@ def _derive_period_type(dt: datetime) -> str:
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str: def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
day_abbr = dt.strftime("%a") day_abbr = dt.strftime("%a")
date_str = f"{dt.month}/{dt.day}" date_str = f"{dt.month}/{dt.day}"
period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "") period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night", "full_24h": "24-Hour"}.get(period_type, "")
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p] parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
return "".join(parts) return "".join(parts)
@@ -3991,7 +4202,15 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
# Prefer per-session period_start/end_hour; fall back to hardcoded defaults. # Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night
eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night
if sh is None or eh is None:
target_date = None
if period_type == 'full_24h':
# 24-hour continuous: keep every row (rows get split across
# Daytime/Evening/Nighttime by hour in the sheet builder). No
# hour-window filtering and no single target date.
is_day_session = False
filtered = [(dt, row) for dt, row in parsed if dt]
elif sh is None or eh is None:
# Legacy defaults based on period_type # Legacy defaults based on period_type
is_day_session = period_type in ('weekday_day', 'weekend_day') is_day_session = period_type in ('weekday_day', 'weekend_day')
sh = 7 if is_day_session else 19 sh = 7 if is_day_session else 19
@@ -3999,8 +4218,9 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
else: else:
is_day_session = eh > sh # crosses midnight when end < start is_day_session = eh > sh # crosses midnight when end < start
target_date = None if period_type == 'full_24h':
if is_day_session: pass # filtered already set above
elif is_day_session:
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date # Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
in_window = lambda h: sh <= h < eh in_window = lambda h: sh <= h < eh
if entry.get("report_date"): if entry.get("report_date"):
@@ -4058,7 +4278,8 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
# Rebuild session label using the correct label date # Rebuild session label using the correct label date
if label_dt and entry["loc_name"]: if label_dt and entry["loc_name"]:
period_str = {"weekday_day": "Day", "weekday_night": "Night", period_str = {"weekday_day": "Day", "weekday_night": "Night",
"weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "") "weekend_day": "Day", "weekend_night": "Night",
"full_24h": "24-Hour"}.get(period_type, "")
day_abbr = label_dt.strftime("%a") day_abbr = label_dt.strftime("%a")
date_label = f"{label_dt.month}/{label_dt.day}" date_label = f"{label_dt.month}/{label_dt.day}"
session_label = "".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p) session_label = "".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p)
@@ -4317,21 +4538,35 @@ async def generate_combined_from_preview(
evening_rows_data = [] evening_rows_data = []
night_rows_data = [] night_rows_data = []
def _row_hour(time_v):
if time_v and ':' in str(time_v):
try:
return int(str(time_v).split(':')[0])
except ValueError:
pass
return 0
for pt, time_v, lmx, l1, l2 in parsed_rows: for pt, time_v, lmx, l1, l2 in parsed_rows:
if pt in PERIOD_TYPE_IS_DAY: if pt in PERIOD_TYPE_IS_DAY:
day_rows_data.append((lmx, l1, l2)) day_rows_data.append((lmx, l1, l2))
elif pt in PERIOD_TYPE_IS_NIGHT: elif pt in PERIOD_TYPE_IS_NIGHT:
# Split by time: Evening = 19:0021:59, Nighttime = 22:0006:59 # Split by time: Evening = 19:0021:59, Nighttime = 22:0006:59
hour = 0 hour = _row_hour(time_v)
if time_v and ':' in str(time_v):
try:
hour = int(str(time_v).split(':')[0])
except ValueError:
pass
if 19 <= hour <= 21: if 19 <= hour <= 21:
evening_rows_data.append((lmx, l1, l2)) evening_rows_data.append((lmx, l1, l2))
else: else:
night_rows_data.append((lmx, l1, l2)) night_rows_data.append((lmx, l1, l2))
elif pt == 'full_24h':
# 24-hour continuous: split each row by hour into the three
# non-overlapping buckets (Daytime 718:59, Evening 1921:59,
# Nighttime 22:0006:59). Empty buckets are dropped downstream.
hour = _row_hour(time_v)
if 7 <= hour < 19:
day_rows_data.append((lmx, l1, l2))
elif 19 <= hour <= 21:
evening_rows_data.append((lmx, l1, l2))
else:
night_rows_data.append((lmx, l1, l2))
else: else:
day_rows_data.append((lmx, l1, l2)) day_rows_data.append((lmx, l1, l2))
+22 -7
View File
@@ -207,24 +207,39 @@ async def control_slm(unit_id: str, action: str):
return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"} return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"}
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: # 30s: a real device start confirms over cellular in a few seconds, but
# leave headroom so a healthy start is never cut off mid-flight (which
# surfaced to users as a misleading "Unknown error").
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post( response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}" f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
) )
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else:
# Surface SLMM's own error detail when it provides one.
detail = f"SLMM returned status {response.status_code}"
try:
body = response.json()
if isinstance(body, dict) and body.get("detail"):
detail = body["detail"]
except Exception:
pass
return {"status": "error", "detail": detail}
except httpx.TimeoutException:
logger.error(f"Timeout controlling {unit_id} (action={action}) via SLMM")
return { return {
"status": "error", "status": "error",
"detail": f"SLMM returned status {response.status_code}" "detail": (
f"Timed out waiting for the device to {action}. "
f"The command may still have been applied — refresh to confirm."
),
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to control {unit_id}: {e}") logger.error(f"Failed to control {unit_id}: {e}")
return { # Never return an empty detail — it renders to users as "Unknown error".
"status": "error", return {"status": "error", "detail": str(e) or f"{type(e).__name__}"}
"detail": str(e)
}
@router.get("/config/{unit_id}", response_class=HTMLResponse) @router.get("/config/{unit_id}", response_class=HTMLResponse)
async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
+1 -1
View File
@@ -8,7 +8,7 @@
// PWA users actually receive the new bundles instead of being stuck on // PWA users actually receive the new bundles instead of being stuck on
// the pre-bump version. Convention: keep it in sync with the Terra-View // the pre-bump version. Convention: keep it in sync with the Terra-View
// version string in backend/main.py. // version string in backend/main.py.
const CACHE_VERSION = 'v0.13.2'; const CACHE_VERSION = 'v0.16.0';
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
+4
View File
@@ -18,6 +18,10 @@ services:
# browser won't send the cookie and the portal breaks). # browser won't send the cookie and the portal breaks).
- SECRET_KEY=${SECRET_KEY:-dev-insecure-change-me} - SECRET_KEY=${SECRET_KEY:-dev-insecure-change-me}
- COOKIE_SECURE=${COOKIE_SECURE:-false} - COOKIE_SECURE=${COOKIE_SECURE:-false}
# Operator login gate. Leave false to ship dark; seed a superadmin via
# backend/operator_admin.py, confirm you can log in, THEN set true to enforce.
# Instant escape hatch: set back to false. See docs/superpowers/specs/2026-06-17-operator-auth-design.md
- OPERATOR_AUTH_ENABLED=${OPERATOR_AUTH_ENABLED:-false}
# Display timezone for server logs + any text-rendered timestamps. # Display timezone for server logs + any text-rendered timestamps.
# DB columns are stored UTC regardless; this only affects what # DB columns are stored UTC regardless; this only affects what
# operators see. Override here for non-US-East deployments. # operators see. Override here for non-US-East deployments.
+1 -1
View File
@@ -4,7 +4,7 @@ Living document — captures known deferred work, in-flight initiatives, and lon
Bump items up/down or strike them through as priorities shift. Source of truth for "what's next" Bump items up/down or strike them through as priorities shift. Source of truth for "what's next"
should be this file plus the `## Current Development Focus` block in `CLAUDE.md`. should be this file plus the `## Current Development Focus` block in `CLAUDE.md`.
Last updated: 2026-06-05 (Terra-View v0.13.3) Last updated: 2026-06-23 (Terra-View v0.16.0)
--- ---
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
# Operator Authentication — Design & Build Plan
**Status:** in development (`feat/operator-auth`) · **Targets:** 0.15.x · **Date:** 2026-06-17
Adds a login + roles to the **internal** Terra-View app — the operator-facing
surface that today has **zero auth**. This is the prerequisite that makes the app
safe to expose to the internet (the office-deployment sequencing: operator auth →
expose). Expands the "Deferred A" section of
[2026-06-15-portal-auth-design.md](2026-06-15-portal-auth-design.md) into a
standalone spec.
## Goal
Anyone reaching the internal app must log in. Three known users to start (you +
two parents), two effective roles, and a **dead-simple password-reset story** for a
family-run shop. Reuses the building blocks the client portal already shipped: the
argon2 hasher (`backend/auth_passwords.py`) and the HMAC signed-cookie pattern
(`backend/portal_auth.py`).
## Scope
**v1 (this spec):** email + password login (argon2) · long-lived "remember this
device" session · brute-force lockout · a **deny-by-default gate** over the whole
internal app · `superadmin`/`admin` roles · **superadmin-only user management** ·
**password reset** (superadmin-resets-anyone + self-service change + forced change)
· a **seed CLI** to bootstrap · the `OPERATOR_AUTH_ENABLED` **feature-flag rollout**.
**Deferred (designed-not-built):** TOTP 2FA (near-term follow-up, `superadmin`
account first) · the `operator` restricted role · email-based self-service
password reset (needs the email infra coming with the report work).
## Principles
1. **Deny by default.** Every route requires a login *except* an explicit allow-list.
A route added next year is protected automatically — you can't forget to gate it.
2. **Can't lock yourself out.** Ship dark behind a feature flag; seed + verify before
enforcing; the flag is an instant escape hatch; a CLI is the break-glass.
3. **Reuse, don't reinvent.** argon2 + the signed-cookie HMAC already exist and are
tested. Operator auth is a thin new layer, not a parallel crypto stack.
4. **Easy recovery.** For a 3-person shop, "forgot my password" must be a 10-second
fix — the superadmin resets it, no email round-trip required.
## Architecture
```
OPERATOR_AUTH_ENABLED=false ──▶ pass everything (app as today)
request ──▶ gate middleware ─┤
└ enabled ─▶ path exempt? ──yes──▶ serve (no login)
│ exempt: /login /logout /health
│ /static/* /portal/* + 3 machine endpoints
└no─▶ valid operator session?
├ no ─▶ HTML: 303 → /login?next=…
│ /api/*: 401 JSON
├ must_change_password ─▶ 303 → /change-password
└ yes ─▶ request.state.operator = user
─▶ route runs; require_role() may 403
```
One **Starlette HTTP middleware** is the gate (not per-route dependencies — a
middleware can't miss a route). It resolves the operator from the cookie using its
own `SessionLocal()` (same pattern the portal WS handler uses), stashes the user on
`request.state.operator`, and a `require_role(...)` dependency reads it for the few
routes that need more than "logged in."
## Data model
New table **`operator_users`** (brand-new → `create_all` builds it on startup, **no
migration needed**, same as the portal's `clients` table):
| Column | Type | Notes |
|---|---|---|
| `id` | str UUID | caller-supplied `str(uuid.uuid4())` (codebase convention) |
| `email` | str, unique, indexed | login handle, stored lowercased |
| `display_name` | str | "Brian", "Dad" — shown in UI + history |
| `password_hash` | str | argon2id via `auth_passwords.hash_password` |
| `role` | str | `"superadmin"` \| `"admin"` (`"operator"` reserved, deferred) |
| `active` | bool, default True | disable a login without deleting |
| `must_change_password` | bool, default False | set on create/reset → forces a change on next login |
| `sessions_valid_from` | datetime, default `utcnow` | bump to invalidate ALL of a user's sessions |
| `failed_login_count` | int, default 0 | lockout counter |
| `locked_until` | datetime, nullable | set after too many bad tries |
| `created_at` | datetime, default `utcnow` | |
| `last_login_at` | datetime, nullable | |
(Deferred columns, not in v1: `totp_secret`, `totp_enabled`.)
**Role ladder** — a rank map so checks read naturally and `operator` slots in later:
```python
_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30}
```
`require_role("admin")` = admin or above; `require_role("superadmin")` for account mgmt.
## Sessions
**New shared module `backend/auth_cookies.py`** — lift the generic signer out so both
auth systems share one implementation:
```python
def sign(payload: dict) -> str # f"{b64url(json)}.{hmac_sha256(b64, SECRET_KEY)}"
def read(raw: str, max_age: int) -> dict | None # verify sig (compare_digest) + iat expiry; None on tamper/expiry
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me") # same env the portal reads
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false") in truthy
```
Operator auth uses it now. (Portal's existing cookie helpers keep working untouched;
migrating them onto `auth_cookies` is an optional later dedupe, gated on the portal
tests staying green — don't destabilize the shipped portal for it.)
**Operator session cookie:** name **`tv_session`** (distinct from the portal's
`portal_session`), payload `{"uid": <id>, "iat": <epoch>}`, `max_age` 30 days
(= the "remember this device" — a small trusted set re-logs in rarely), `httponly`,
`samesite=lax`, `secure=COOKIE_SECURE`.
**Validation each request** (`current_operator(request, db)`): read+verify cookie →
load `OperatorUser` by `uid` → require `active`, `iat >= sessions_valid_from`
(epoch), and not `locked_until > now`. Any failure → no session. Bumping
`sessions_valid_from` (on password change / "log out everywhere") instantly kills all
live cookies with no session table.
## Authorization
**The gate (middleware) exempt list:**
- `/login`, `/logout`, `/health`, `/static/*`, plus PWA assets
(`/manifest.json`, `/sw.js`, `/favicon.ico`)
- `/portal/*` — the client portal keeps its own (separate) auth
- **machine endpoints (LAN-only, automated, no human):** `/emitters/report`,
`/api/series3/heartbeat`, `/api/series4/heartbeat`
`/change-password` is **not** exempt — it requires a logged-in session (you change
*your own* password). It's only *excluded from the `must_change_password` redirect*,
so a forced-change user can actually reach it (no redirect loop).
**Permission split — minimal by design.** Because the `operator` role is deferred,
every real v1 user is `admin` or `superadmin`, so "logged in" already means "full
app." The *only* thing gated above plain-admin is **account management**
`require_role("superadmin")` on the user-management routes. Everything else just
requires a valid session (the middleware). One extra guard, not a sprawling matrix.
**The flag governs everything.** Both the middleware *and* `require_role` respect
`OPERATOR_AUTH_ENABLED`: when it's off, neither enforces anything (no session is set,
and `require_role` passes through) — the app behaves exactly as it does today. When
it's on, the middleware guarantees `request.state.operator` is set before any
`require_role` check runs.
## Password management & reset *(the emphasized requirement)*
Three paths, no email infra required:
1. **Superadmin resets anyone** — from the user-management UI, "Reset password" →
generates a strong password (`auth_passwords.generate_password`), stores its hash,
sets `must_change_password=True`, **shows the temp password once** for you to hand
off. Covers "easy for *me* to reset *their* password."
2. **Self-service change**`/change-password` (any logged-in user): current + new.
Used for routine changes **and** the forced post-reset change. On success, bump
`sessions_valid_from` (logs out other devices) and clear `must_change_password`.
3. **Forced change** — after a reset/first login, `must_change_password=True` → the
gate routes them to `/change-password` until they set their own.
**Forgot it entirely (can't log in):** v1 has **no email reset**`/login` shows
"Forgot your password? Contact your administrator," and you (superadmin) reset it via
the UI or CLI. For a 3-person shop that's a text message, not a feature. (Email-based
self-service is the deferred follow-up once email infra lands.)
## Bootstrapping — seed CLI
`backend/operator_admin.py` (modeled on the existing `portal_admin.py`), run inside
the container against the live DB:
```
create-superadmin --email you@x.com --name "Brian" # prompts for a password (or --generate)
create-user --email dad@x.com --name "Dad" --role admin # generates a temp password, must_change=True
reset-password --email dad@x.com # generates a temp, must_change=True
list # users + roles + active/locked state
disable --email dad@x.com / enable --email dad@x.com
```
The CLI is the bootstrap (first superadmin, before any UI is reachable) **and** the
break-glass (locked out / forgot everything).
## Account-management UI (superadmin-only)
`GET /admin/users` (page, `require_role("superadmin")`) + JSON endpoints:
- list operators (name, email, role, active, locked, last login)
- add operator (email, name, role) → temp password shown once
- reset password → temp shown once
- enable / disable, change role
Template `templates/admin/users.html`. Admins (parents) don't see this; superadmin only.
## Login / logout / change-password
- `GET /login``templates/login.html` (email + password, optional `?next=`).
- `POST /login` → lowercase email, lockout check, argon2 verify; on success set
`tv_session`, stamp `last_login_at`, clear `failed_login_count`, redirect to `next`
or `/`; on `must_change_password``/change-password`; on fail → increment +
generic "invalid email or password" (no user-enumeration), lock after 5 → 15 min.
- `GET /logout` → clear cookie → `/login`.
- `GET/POST /change-password``templates/change_password.html`.
## Error handling
- Wrong email/password → generic message, increment fail count.
- ≥5 fails → "too many attempts, try again in 15 minutes" (`locked_until`).
- No/expired/forged cookie → HTML routes 303→`/login?next=…`; `/api/*` → 401 JSON.
- Disabled / role-changed / password-changed-elsewhere → bounced on next request
(re-validated against the DB every request).
- Superadmin-only route hit by an admin → 403.
## Rollout — the no-self-lockout sequence
1. Ship with `OPERATOR_AUTH_ENABLED=false` (default) → the middleware short-circuits,
app behaves **exactly as today**. Deploying can't break or lock anything.
2. Seed your `superadmin` via `operator_admin.py`.
3. Hit `/login` and confirm you get a session **while the flag is still off** (the
login routes work regardless of the flag).
4. Flip `OPERATOR_AUTH_ENABLED=true` → the gate enforces. Your cookie is valid → you're
in. Anything wrong → flip it back off (instant escape hatch).
5. Create your parents' accounts from `/admin/users` (temp passwords, they change on
first login).
- **Break-glass:** `operator_admin.py reset-password` / `create-superadmin` in the
container; or flag off.
## Testing
Reuses the pytest harness from the portal work (`docker exec … python -m pytest`).
- **Middleware:** flag off → every path passes; flag on → exempt paths + the 3 machine
endpoints pass with no cookie, a gated HTML path 303s to `/login`, a gated `/api/*`
path 401s, `must_change_password` user is routed to `/change-password`.
- **Login:** success sets `tv_session`; wrong password rejected + counts; 5 wrong →
locked (even correct password refused).
- **Roles:** `require_role("superadmin")` route → admin gets 403, superadmin 200.
- **Sessions:** bumping `sessions_valid_from` invalidates an existing cookie.
- **Password:** self-change works + clears `must_change_password`; superadmin reset
sets a new hash + `must_change_password` + returns the raw once.
- **Machine endpoints:** `/api/series3/heartbeat` etc. still 200 with the gate ON and
no cookie (regression guard so we never silently break the watchers).
## File structure
| File | Responsibility |
|---|---|
| `backend/auth_cookies.py` *(new)* | generic `sign`/`read` + `SECRET_KEY`/`COOKIE_SECURE` |
| `backend/models.py` | add `OperatorUser` |
| `backend/operator_auth.py` *(new)* | `current_operator`, `require_role`, the gate middleware, login/lockout helpers |
| `backend/routers/operator_auth_routes.py` *(new)* | `/login`, `/logout`, `/change-password` |
| `backend/routers/operator_users.py` *(new)* | `/admin/users` page + CRUD (superadmin) |
| `backend/operator_admin.py` *(new)* | seed/break-glass CLI |
| `backend/main.py` | register the gate middleware + routers; `OPERATOR_AUTH_ENABLED` |
| `templates/login.html`, `templates/change_password.html`, `templates/admin/users.html` *(new)* | UI |
## Going to prod
- New table auto-creates; **no migration**. Just code + seeding.
- Set a real `SECRET_KEY` (shared with the portal cookie) and `COOKIE_SECURE=true`
once on HTTPS — same env knobs already wired in `docker-compose.yml`.
- Operator auth is what makes internet-exposing the internal app safe; pair with the
(deferred) office deployment + reverse-proxy/TLS work.
## Security notes
- Deny-by-default; client-supplied ids never trusted; every request re-validates the
session against the DB (instant revoke via `active` / `sessions_valid_from`).
- Passwords argon2-hashed; generic login errors (no user-enumeration); lockout on
brute force; raw temp passwords shown once, never stored or logged.
- Cookies `HttpOnly` + `SameSite=Lax` + `Secure` (on TLS), HMAC-signed with server-side
`iat` expiry.
- **Known residual until deploy:** without TLS the password crosses the wire in
cleartext — fix is the deployment-phase TLS (Synology Let's Encrypt / Cloudflare
Tunnel). The login is still a massive improvement over today's zero-auth exposure.
- TOTP 2FA is the near-term follow-up (superadmin first), especially without the UniFi
edge in front on the home network.
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Rebuild + redeploy a SINGLE compose service without touching the rest of the
# stack. The whole-stack rebuild you keep hitting happens because `web-app`
# depends_on slmm + sfm, so `compose up --build web-app` rebuilds the entire
# dependency tree. `--no-deps` is the fix: build + recreate ONLY this service.
#
# Usage:
# ./redeploy.sh # rebuild + redeploy web-app (prod-style, :8001)
# ./redeploy.sh terra-view # the dev container (:1001, bind-mounted source)
# ./redeploy.sh sfm # any single service
#
# Tip: the dev `terra-view` service bind-mounts the source, so for plain
# code/template edits you usually only need: docker compose restart terra-view
# Rebuild it only when requirements.txt or the Dockerfile changed.
set -euo pipefail
cd "$(dirname "$0")"
SVC="${1:-web-app}"
if ! docker compose config --services | grep -qx "$SVC"; then
echo "✗ '$SVC' is not a service in this compose project."
echo " Available: $(docker compose config --services | paste -sd' ')"
exit 1
fi
echo "▶ Rebuilding '$SVC' (dependencies untouched)…"
docker compose build "$SVC"
echo "▶ Recreating ONLY '$SVC'…"
docker compose up -d --no-deps "$SVC"
echo
echo "✓ '$SVC' redeployed. Current state:"
docker compose ps "$SVC"
+36 -3
View File
@@ -207,9 +207,19 @@ function _renderPdCard(pd) {
</button> </button>
</div>`; </div>`;
} else if (pd.status === 'assigned') { } else if (pd.status === 'assigned') {
const reforward = pd.coordinates
? `<button onclick="reforwardInfo('${_esc(pd.id)}')"
class="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
title="Re-push this capture's GPS coordinates onto its assigned location">
<svg class="w-3.5 h-3.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>
Reforward info
</button>`
: '';
footerActions = `<div class="mt-3 text-xs text-green-700 dark:text-green-400"> footerActions = `<div class="mt-3 text-xs text-green-700 dark:text-green-400">
Promoted ${_fmtDateTime(pd.promoted_at)} → assignment <span class="font-mono">${_esc((pd.resulting_assignment_id || '').slice(0, 8))}…</span> Promoted ${_fmtDateTime(pd.promoted_at)} → assignment <span class="font-mono">${_esc((pd.resulting_assignment_id || '').slice(0, 8))}…</span>
</div>`; </div>${reforward}`;
} else if (pd.status === 'cancelled') { } else if (pd.status === 'cancelled') {
footerActions = `<div class="mt-3 text-xs text-gray-500 dark:text-gray-400"> footerActions = `<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''} Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''}
@@ -266,6 +276,12 @@ async function openClassifyModal(pendingId) {
document.getElementById('new-project-name').value = ''; document.getElementById('new-project-name').value = '';
document.getElementById('new-location-name').value = ''; document.getElementById('new-location-name').value = '';
// Reset the submit button — the success path closes the modal without
// clearing it, so without this it stays stuck on "Classifying…" on reopen.
const submitBtn = document.getElementById('classify-submit');
submitBtn.disabled = false;
submitBtn.textContent = 'Classify';
// Coords hint for "use captured coords" checkbox. // Coords hint for "use captured coords" checkbox.
const hint = document.getElementById('captured-coords-hint'); const hint = document.getElementById('captured-coords-hint');
if (pd.coordinates) { if (pd.coordinates) {
@@ -295,9 +311,10 @@ function closeClassifyModal() {
async function _loadProjects() { async function _loadProjects() {
try { try {
const r = await fetch('/api/projects/list'); // Must be the JSON endpoint — /api/projects/list returns HTML cards.
const r = await fetch('/api/projects/list-json');
const data = r.ok ? await r.json() : { projects: [] }; const data = r.ok ? await r.json() : { projects: [] };
// Endpoint shape varies; tolerate either { projects: [...] } or a flat array. // Tolerate either { projects: [...] } or a flat array.
_pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []); _pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []);
} catch (e) { } catch (e) {
_pdState.projectsCache = []; _pdState.projectsCache = [];
@@ -452,6 +469,22 @@ async function cancelPending(pendingId) {
} }
} }
// Re-push a promoted capture's GPS coordinates onto its assigned location.
async function reforwardInfo(pendingId) {
try {
const r = await fetch(`/api/deployments/pending/${pendingId}/resync-location`, {
method: 'POST',
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.detail || 'HTTP ' + r.status);
const msg = `Coordinates synced to "${j.location_name}": ${j.coordinates}`;
if (window.showToast) showToast(msg, 'success'); else alert(msg);
} catch (e) {
const msg = 'Reforward failed: ' + e.message;
if (window.showToast) showToast(msg, 'error'); else alert(msg);
}
}
// Kick off the initial load. // Kick off the initial load.
loadPdList(); loadPdList();
// Refresh awaiting count every 30s for the badge. // Refresh awaiting count every 30s for the badge.
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Operator Accounts{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto p-4">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold">Operator Accounts</h1>
<button id="add-user-btn" class="px-3 py-2 rounded bg-orange-500 hover:bg-orange-600 text-white text-sm">+ Add operator</button>
</div>
<div id="temp-pw-banner" class="hidden mb-4 px-3 py-2 rounded bg-emerald-900/60 text-emerald-100 text-sm"></div>
<table class="w-full text-sm">
<thead><tr class="text-left border-b border-slate-600">
<th class="py-2">Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th></th>
</tr></thead>
<tbody id="user-rows"></tbody>
</table>
</div>
<script>
const $ = (s) => document.querySelector(s);
function showTemp(email, pw) {
const b = $("#temp-pw-banner");
b.textContent = `Temporary password for ${email}: ${pw} — copy it now, it won't be shown again.`;
b.classList.remove("hidden");
}
async function load() {
const r = await fetch("/api/admin/users");
const { users } = await r.json();
$("#user-rows").innerHTML = users.map(u => `
<tr class="border-b border-slate-700">
<td class="py-2">${u.display_name}</td>
<td>${u.email}</td>
<td>
<select data-role="${u.id}" class="bg-slate-700 rounded px-1 py-0.5">
<option value="admin"${u.role==='admin'?' selected':''}>admin</option>
<option value="superadmin"${u.role==='superadmin'?' selected':''}>superadmin</option>
</select>
</td>
<td>${u.active ? 'active' : 'disabled'}${u.locked ? ' (locked)' : ''}</td>
<td>${u.last_login_at || '—'}</td>
<td class="text-right space-x-2">
<button data-reset="${u.id}" data-email="${u.email}" class="text-orange-400 hover:underline">Reset pw</button>
<button data-toggle="${u.id}" data-active="${u.active}" class="text-slate-300 hover:underline">${u.active ? 'Disable' : 'Enable'}</button>
</td>
</tr>`).join("");
}
document.addEventListener("click", async (e) => {
if (e.target.dataset.reset) {
const r = await fetch(`/api/admin/users/${e.target.dataset.reset}/reset-password`, {method:"POST"});
const d = await r.json(); showTemp(e.target.dataset.email, d.password); load();
} else if (e.target.dataset.toggle) {
const action = e.target.dataset.active === "true" ? "disable" : "enable";
await fetch(`/api/admin/users/${e.target.dataset.toggle}/${action}`, {method:"POST"}); load();
} else if (e.target.id === "add-user-btn") {
const email = prompt("Email?"); if (!email) return;
const name = prompt("Display name?") || email;
const role = prompt("Role (admin / superadmin)?", "admin") || "admin";
const r = await fetch("/api/admin/users", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({email, name, role})});
if (r.ok) { const d = await r.json(); showTemp(email, d.password); load(); }
else { alert((await r.json()).detail || "Failed"); }
}
});
document.addEventListener("change", async (e) => {
if (e.target.dataset.role) {
await fetch(`/api/admin/users/${e.target.dataset.role}/role`, {method:"POST",
headers:{"Content-Type":"application/json"}, body: JSON.stringify({role: e.target.value})});
load();
}
});
load();
</script>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Change password · Terra-View</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-sm p-8 bg-slate-800 rounded-xl shadow-lg">
<h1 class="text-xl font-semibold mb-2 text-center">Change your password</h1>
{% if must_change %}
<p class="mb-4 text-sm text-amber-300 text-center">Please set a new password to continue.</p>
{% endif %}
{% if error %}
<div class="mb-4 px-3 py-2 rounded bg-red-900/60 text-red-200 text-sm">{{ error }}</div>
{% endif %}
<form method="post" action="/change-password" class="space-y-4">
<div>
<label class="block text-sm mb-1" for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password" required
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
</div>
<div>
<label class="block text-sm mb-1" for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" minlength="8" required
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
</div>
<div>
<label class="block text-sm mb-1" for="confirm_password">Confirm new password</label>
<input id="confirm_password" name="confirm_password" type="password" minlength="8" required
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
</div>
<button type="submit"
class="w-full py-2 rounded bg-orange-500 hover:bg-orange-600 font-medium">Update password</button>
</form>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Terra-View</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-sm p-8 bg-slate-800 rounded-xl shadow-lg">
<h1 class="text-xl font-semibold mb-6 text-center">Terra-View</h1>
{% if error %}
<div class="mb-4 px-3 py-2 rounded bg-red-900/60 text-red-200 text-sm">{{ error }}</div>
{% endif %}
<form method="post" action="/login{% if next %}?next={{ next }}{% endif %}" class="space-y-4">
<div>
<label class="block text-sm mb-1" for="email">Email</label>
<input id="email" name="email" type="email" autofocus required
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
</div>
<div>
<label class="block text-sm mb-1" for="password">Password</label>
<input id="password" name="password" type="password" required
class="w-full px-3 py-2 rounded bg-slate-700 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-orange-500">
</div>
<button type="submit"
class="w-full py-2 rounded bg-orange-500 hover:bg-orange-600 font-medium">Sign in</button>
</form>
<p class="mt-4 text-xs text-slate-400 text-center">Forgot your password? Contact your administrator.</p>
</div>
</body>
</html>
@@ -74,12 +74,19 @@
{% else %} {% else %}
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span> <span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
{% endif %} {% endif %}
{% if item.last_event %}
<span>Last event: <span class="text-gray-700 dark:text-gray-300">{{ item.last_event[:16] }}</span></span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Right column: small assign/unassign pill + 3-dot menu --> <!-- Right column: live status chip + small assign/unassign pill + 3-dot menu -->
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<!-- Live status chip — painted by the Overview live poller
(paintInlineLive) keyed on data-loc-live. Hidden until
there's live data for this location. -->
<span class="nrl-live-chip hidden" data-loc-live="{{ item.location.id }}"></span>
{% if not item.assignment %} {% if not item.assignment %}
<!-- Primary action: visible because the unassigned card <!-- Primary action: visible because the unassigned card
is most likely getting clicked on right after creation --> is most likely getting clicked on right after creation -->
@@ -3,13 +3,14 @@
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div> <div>
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ project.name }}</h2> <h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ project.name }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> {# Identity line — project number / client, not a module name. The
{% if project_type %} enabled modules are already shown as chips in the page header. #}
{{ project_type.name }} {% set _idbits = [] %}
{% else %} {% if project.project_number %}{% set _ = _idbits.append(project.project_number) %}{% endif %}
Project {% if project.client_name %}{% set _ = _idbits.append(project.client_name) %}{% endif %}
{% if _idbits %}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _idbits | join(' · ') }}</p>
{% endif %} {% endif %}
</p>
</div> </div>
{% if project.status == 'upcoming' %} {% if project.status == 'upcoming' %}
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span> <span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
@@ -48,35 +49,33 @@
</div> </div>
</div> </div>
{# Separate location lists per module type so vibration points and sound NRLs
don't get mixed in one list. Build the section set from the enabled modules. #}
{% set loc_sections = [] %}
{% if 'vibration_monitoring' in modules %}{% set _ = loc_sections.append(('vibration', 'Vibration Locations', 'Add Location')) %}{% endif %}
{% if 'sound_monitoring' in modules %}{% set _ = loc_sections.append(('sound', 'NRLs', 'Add NRL')) %}{% endif %}
{% if not loc_sections %}{% set _ = loc_sections.append(('', 'Locations', 'Add Location')) %}{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-6">
{% for ltype, title, add_label in loc_sections %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %} <button onclick="openLocationModal('{{ ltype }}')" class="text-sm text-seismo-orange hover:text-seismo-navy">{{ add_label }}</button>
NRLs
{% else %}
Locations
{% endif %}
</h3>
<button onclick="openLocationModal('{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}sound{% elif 'vibration_monitoring' in modules and 'sound_monitoring' not in modules %}vibration{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
Add NRL
{% else %}
Add Location
{% endif %}
</button>
</div> </div>
<div id="project-locations" <div id="project-locations{{ '-' + ltype if ltype else '' }}"
hx-get="/api/projects/{{ project.id }}/locations{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}?location_type=sound{% endif %}" hx-get="/api/projects/{{ project.id }}/locations{{ '?location_type=' + ltype if ltype else '' }}"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="animate-pulse space-y-3"> <div class="animate-pulse space-y-3">
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div> <div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div> <div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
</div> </div>
</div> </div>
</div> </div>
{% endfor %}
</div>
{# Location map — uses the reusable partial that fetches from {# Location map — uses the reusable partial that fetches from
/api/projects/{p}/locations-json. Same render is reused on the /api/projects/{p}/locations-json. Same render is reused on the
@@ -37,6 +37,12 @@
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Vibration Monitoring Vibration Monitoring
{% else %}{{ m }}{% endif %} {% else %}{{ m }}{% endif %}
{% set mstatus = (module_status or {}).get(m, 'active') %}
{% if mstatus == 'completed' %}
<span class="ml-0.5 inline-flex items-center px-1.5 py-0 rounded-full text-[10px] font-semibold bg-blue-200 text-blue-900 dark:bg-blue-800/70 dark:text-blue-100" title="This module is marked completed">✓ Done</span>
{% elif mstatus == 'on_hold' %}
<span class="ml-0.5 inline-flex items-center px-1.5 py-0 rounded-full text-[10px] font-semibold bg-amber-200 text-amber-900 dark:bg-amber-800/70 dark:text-amber-100" title="This module is on hold">On hold</span>
{% endif %}
<button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module"> <button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg> <svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button> </button>
@@ -47,50 +53,11 @@
Add Module Add Module
</button> </button>
</div> </div>
{% if project.data_collection_mode == 'remote' %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Remote
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
Manual
</span>
{% endif %}
</div> </div>
</div> </div>
<!-- Project Actions --> <!-- Project Actions — project-level only. Sound-specific actions
(Combined Report, Night Report, Report Settings) live in the Sound tab. -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{% if 'sound_monitoring' in modules %}
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Combined Report
</a>
<button onclick="openNightReportModal()"
title="Last night's noise vs baseline, per location (FTP report pipeline)"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
<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="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"></path>
</svg>
Night Report
</button>
<button onclick="openReportSettings('{{ project.id }}')"
title="Nightly report settings — schedule, baseline range, recipients"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
{% endif %}
<button onclick="openMergeModal()" <button onclick="openMergeModal()"
title="Merge this project into another (consolidates duplicates)" title="Merge this project into another (consolidates duplicates)"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm"> class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
+103 -76
View File
@@ -1,92 +1,119 @@
<!-- Project List Grid --> <!-- Project List Grid -->
{% if projects %} {% if projects %}
{% for item in projects %} {% for item in projects %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg hover:shadow-xl transition-shadow"> {% set p = item.project %}
<a href="/projects/{{ item.project.id }}" class="block p-6"> {% set mods = item.modules or [] %}
<!-- Project Header --> {% set mstatus = item.module_status or {} %}
<div class="flex items-start justify-between mb-4"> {% set has_sound = 'sound_monitoring' in mods %}
<div class="flex-1"> {% set has_vib = 'vibration_monitoring' in mods %}
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1"> <div class="group relative flex flex-col bg-white dark:bg-slate-800 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 overflow-hidden">
{{ item.project.name }} <!-- Accent strip — reflects the project's module mix -->
</h3> <div class="absolute inset-x-0 top-0 h-1
<p class="text-sm text-gray-500 dark:text-gray-400 flex items-center"> {% if has_sound and has_vib %}bg-gradient-to-r from-seismo-orange to-blue-500
{% if item.project_type %} {% elif has_sound %}bg-seismo-orange
{% if item.project_type.id == 'sound_monitoring' %} {% elif has_vib %}bg-blue-500
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg> <a href="/projects/{{ p.id }}" class="block flex-1 p-6 pt-7">
{% elif item.project_type.id == 'vibration_monitoring' %} <!-- Header: name + identity + status -->
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex items-start justify-between gap-3 mb-3">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path> <div class="min-w-0">
</svg> <h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ p.name }}</h3>
{% set idbits = [] %}
{% if p.project_number %}{% set _ = idbits.append(p.project_number) %}{% endif %}
{% if p.client_name %}{% set _ = idbits.append(p.client_name) %}{% endif %}
{% if idbits %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">{{ idbits | join(' · ') }}</p>
{% endif %}
</div>
{% if p.status == 'active' %}
<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-400 rounded-full">Active</span>
{% elif p.status == 'upcoming' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
{% elif p.status == 'on_hold' %}
<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-400 rounded-full">On Hold</span>
{% elif p.status == 'completed' %}
<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-400 rounded-full">Completed</span>
{% elif p.status == 'archived' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400 rounded-full">Archived</span>
{% endif %}
</div>
<!-- Description -->
{% if p.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">{{ p.description }}</p>
{% endif %}
<!-- Per-module stats — each module shows only its own, relevant counts.
These lines double as the module legend (identity + status), so a
separate chip row would be redundant. -->
{% set ms = item.module_stats %}
{% if ms %}
<div class="space-y-2.5 pt-4 border-t border-gray-100 dark:border-gray-700/60">
{% if 'vibration' in ms %}
{% set vst = mstatus.get('vibration_monitoring', 'active') %}
<div class="flex items-baseline gap-2.5 text-sm flex-wrap">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Vibration
{% if vst == 'completed' %}<span title="Completed"></span>{% elif vst == 'on_hold' %}<span class="opacity-80" title="On hold"></span>{% endif %}
</span>
<span class="text-gray-500 dark:text-gray-400">
<b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.vibration.locations }}</b> location{{ '' if ms.vibration.locations == 1 else 's' }}
· <b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.vibration.units }}</b> unit{{ '' if ms.vibration.units == 1 else 's' }}
</span>
</div>
{% endif %}
{% if 'sound' in ms %}
{% set sst = mstatus.get('sound_monitoring', 'active') %}
<div class="flex items-baseline gap-2.5 text-sm flex-wrap">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
Sound
{% if sst == 'completed' %}<span title="Completed"></span>{% elif sst == 'on_hold' %}<span class="opacity-80" title="On hold"></span>{% endif %}
</span>
<span class="text-gray-500 dark:text-gray-400">
<b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.sound.locations }}</b> NRL{{ '' if ms.sound.locations == 1 else 's' }}
· <b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.sound.units }}</b> unit{{ '' if ms.sound.units == 1 else 's' }}
· {% if ms.sound.recording > 0 %}<b class="font-semibold text-green-600 dark:text-green-400 tabular-nums">{{ ms.sound.recording }}</b> recording{% else %}<span class="text-gray-400 dark:text-gray-500">0 recording</span>{% endif %}
</span>
</div>
{% endif %}
</div>
{% else %} {% else %}
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <!-- Fallback: project with no modules enabled yet -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"></path> <div class="grid grid-cols-2 gap-3 pt-4 border-t border-gray-100 dark:border-gray-700/60">
</svg>
{% endif %}
{{ item.project_type.name }}
{% endif %}
</p>
</div>
<!-- Status Badge -->
{% if item.project.status == 'active' %}
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
Active
</span>
{% elif item.project.status == 'on_hold' %}
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
On Hold
</span>
{% elif item.project.status == 'completed' %}
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
Completed
</span>
{% elif item.project.status == 'archived' %}
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400 rounded-full">
Archived
</span>
{% endif %}
</div>
<!-- Project Description -->
{% if item.project.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{{ item.project.description }}
</p>
{% endif %}
<!-- Project Stats -->
<div class="grid grid-cols-3 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">Locations</p> <p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Locations</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p> <p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">Units</p> <p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Units</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p> <p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p>
</div> </div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Active</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{% if item.active_session_count > 0 %}
<span class="text-green-600 dark:text-green-400">{{ item.active_session_count }}</span>
{% else %}
{{ item.active_session_count }}
{% endif %}
</p>
</div>
</div>
<!-- Client Info -->
{% if item.project.client_name %}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400">
Client: <span class="font-medium text-gray-700 dark:text-gray-300">{{ item.project.client_name }}</span>
</p>
</div> </div>
{% endif %} {% endif %}
</a> </a>
<!-- Per-module quick-open footer — jumps straight into that module's tab -->
{% if has_sound or has_vib %}
<div class="flex items-stretch border-t border-gray-100 dark:border-gray-700/60 divide-x divide-gray-100 dark:divide-gray-700/60">
{% if has_sound %}
<a href="/projects/{{ p.id }}#sound"
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-semibold text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 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.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
Sound
</a>
{% endif %}
{% if has_vib %}
<a href="/projects/{{ p.id }}#vibration"
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 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="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Vibration
</a>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
+49 -4
View File
@@ -13,12 +13,14 @@
'weekday_night': 'Weekday Night', 'weekday_night': 'Weekday Night',
'weekend_day': 'Weekend Day', 'weekend_day': 'Weekend Day',
'weekend_night': 'Weekend Night', 'weekend_night': 'Weekend Night',
'full_24h': '24-Hour',
} %} } %}
{% set period_colors = { {% set period_colors = {
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', 'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', 'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', 'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', 'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
'full_24h': 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300',
} %} } %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors" <div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
@@ -54,6 +56,7 @@
<div class="relative" id="period-wrap-{{ s.id }}"> <div class="relative" id="period-wrap-{{ s.id }}">
<button onclick="openPeriodEditor('{{ s.id }}')" <button onclick="openPeriodEditor('{{ s.id }}')"
id="period-badge-{{ s.id }}" id="period-badge-{{ s.id }}"
data-current-period="{{ s.period_type or '' }}"
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}" class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
title="Click to edit period type and hours"> title="Click to edit period type and hours">
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span> <span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
@@ -78,6 +81,12 @@
{{ pt_label }} {{ pt_label }}
</button> </button>
{% endfor %} {% endfor %}
<button onclick="selectPeriodType('{{ s.id }}', 'full_24h')"
id="pt-btn-{{ s.id }}-full_24h"
class="period-type-btn col-span-2 text-xs py-1 px-2 rounded border transition-colors
{% if s.period_type == 'full_24h' %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
24-Hour
</button>
</div> </div>
</div> </div>
@@ -229,14 +238,17 @@ const PERIOD_COLORS = {
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
full_24h: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300',
}; };
const PERIOD_LABELS = { const PERIOD_LABELS = {
weekday_day: 'Weekday Day', weekday_day: 'Weekday Day',
weekday_night: 'Weekday Night', weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day', weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night', weekend_night: 'Weekend Night',
full_24h: '24-Hour',
}; };
// Default hours for each period type // Default hours for each period type. full_24h has no window (the report splits
// its rows by hour), so its hour inputs are cleared + disabled in the editor.
const PERIOD_DEFAULT_HOURS = { const PERIOD_DEFAULT_HOURS = {
weekday_day: {start: 7, end: 19}, weekday_day: {start: 7, end: 19},
weekday_night: {start: 19, end: 7}, weekday_night: {start: 19, end: 7},
@@ -260,6 +272,20 @@ function openPeriodEditor(sessionId) {
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden'); if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
}); });
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden'); document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
// Reflect the current period type's hour-input state on open (24-Hour
// has no window, so its hour inputs open disabled).
const cur = document.getElementById('period-badge-' + sessionId)?.dataset?.currentPeriod;
const sh = document.getElementById('period-start-hr-' + sessionId);
const eh = document.getElementById('period-end-hr-' + sessionId);
const disable = cur === 'full_24h';
[sh, eh].forEach(el => {
if (!el) return;
el.disabled = disable;
el.classList.toggle('opacity-50', disable);
el.classList.toggle('cursor-not-allowed', disable);
if (disable) el.placeholder = 'n/a';
});
} }
function closePeriodEditor(sessionId) { function closePeriodEditor(sessionId) {
@@ -281,14 +307,32 @@ function selectPeriodType(sessionId, pt) {
btn.classList.toggle('text-gray-600', !isSelected); btn.classList.toggle('text-gray-600', !isSelected);
btn.classList.toggle('dark:text-gray-400', !isSelected); btn.classList.toggle('dark:text-gray-400', !isSelected);
}); });
// Fill default hours // Hour inputs: full_24h has no window — clear + disable them. Other types
const defaults = PERIOD_DEFAULT_HOURS[pt]; // re-enable and fill their default window if empty.
if (defaults) {
const sh = document.getElementById('period-start-hr-' + sessionId); const sh = document.getElementById('period-start-hr-' + sessionId);
const eh = document.getElementById('period-end-hr-' + sessionId); const eh = document.getElementById('period-end-hr-' + sessionId);
if (pt === 'full_24h') {
[sh, eh].forEach(el => {
if (!el) return;
el.value = '';
el.disabled = true;
el.classList.add('opacity-50', 'cursor-not-allowed');
el.placeholder = 'n/a';
});
} else {
const defaults = PERIOD_DEFAULT_HOURS[pt];
[sh, eh].forEach(el => {
if (!el) return;
el.disabled = false;
el.classList.remove('opacity-50', 'cursor-not-allowed');
});
if (sh) sh.placeholder = 'e.g. 19';
if (eh) eh.placeholder = 'e.g. 7';
if (defaults) {
if (sh && !sh.value) sh.value = defaults.start; if (sh && !sh.value) sh.value = defaults.start;
if (eh && !eh.value) eh.value = defaults.end; if (eh && !eh.value) eh.value = defaults.end;
} }
}
} }
async function savePeriodEditor(sessionId) { async function savePeriodEditor(sessionId) {
@@ -315,6 +359,7 @@ async function savePeriodEditor(sessionId) {
const badge = document.getElementById('period-badge-' + sessionId); const badge = document.getElementById('period-badge-' + sessionId);
const label = document.getElementById('period-label-' + sessionId); const label = document.getElementById('period-label-' + sessionId);
const newPt = result.period_type; const newPt = result.period_type;
badge.dataset.currentPeriod = newPt || '';
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c)); ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
if (newPt && PERIOD_COLORS[newPt]) { if (newPt && PERIOD_COLORS[newPt]) {
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean)); badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
+555 -1
View File
@@ -85,6 +85,36 @@
<div id="tab-content"> <div id="tab-content">
<!-- Overview Tab --> <!-- Overview Tab -->
<div id="overview-tab" class="tab-panel"> <div id="overview-tab" class="tab-panel">
<!-- Live monitoring (sound) — self-contained, refreshes itself every 15s.
Kept OUTSIDE #project-dashboard so the 30s htmx swap below never
clobbers its DOM/timer. Shown only for projects with sound NRLs. -->
<div id="live-stats-section" class="hidden mb-6">
<div class="flex flex-wrap items-center gap-2.5 mb-4">
<span class="text-[10px] uppercase tracking-[0.18em] text-seismo-orange/90 font-semibold">Live monitoring</span>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-seismo-orange"></span>
</span>
<b id="ls-live" class="text-lg font-semibold text-seismo-orange tabular-nums"></b>
<span class="text-xs text-gray-500 dark:text-gray-400">live</span>
</div>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
<b id="ls-offline" class="text-lg font-semibold text-gray-700 dark:text-gray-200 tabular-nums"></b>
<span class="text-xs text-gray-500 dark:text-gray-400">offline</span>
</div>
<div id="ls-loudest-wrap" class="hidden inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
<span class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Loudest now</span>
<b id="ls-loudest" class="text-lg font-semibold text-seismo-orange tabular-nums"></b>
<span class="text-xs text-gray-500 dark:text-gray-400">dB</span>
<span id="ls-loudest-loc" class="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<span class="text-[11px] text-gray-400 dark:text-gray-500 ml-auto">auto-refresh 15s</span>
</div>
<div id="ls-tiles" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>
</div>
<div id="project-dashboard" <div id="project-dashboard"
hx-get="/api/projects/{{ project_id }}/dashboard" hx-get="/api/projects/{{ project_id }}/dashboard"
hx-trigger="load, every 30s" hx-trigger="load, every 30s"
@@ -101,13 +131,29 @@
<!-- Vibration Tab --> <!-- Vibration Tab -->
<div id="vibration-tab" class="tab-panel hidden"> <div id="vibration-tab" class="tab-panel hidden">
<!-- Vibration module toolbar — per-module status, scoped to this module -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">Module status</span>
<select id="vibration-module-status" onchange="setModuleStatus('vibration_monitoring', this.value, this)"
class="text-sm font-medium bg-transparent border-0 focus:ring-0 text-gray-900 dark:text-white cursor-pointer py-0 pr-6">
<option value="active">Active</option>
<option value="on_hold">On hold</option>
<option value="completed">Completed</option>
</select>
</div>
</div>
<!-- Vibration sub-nav --> <!-- Vibration sub-nav -->
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700"> <div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
<button id="vib-sub-locations-btn" onclick="switchVibSubTab('locations')" <button id="vib-sub-locations-btn" onclick="switchVibSubTab('locations')"
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap"> class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
Locations Locations
</button> </button>
<!-- Future sub-tabs: Sessions, Data Files --> <button id="vib-sub-events-btn" onclick="switchVibSubTab('events')"
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
Events
</button>
</div> </div>
<!-- Vibration Locations sub-panel --> <!-- Vibration Locations sub-panel -->
@@ -153,10 +199,101 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Vibration Events sub-panel — project-wide events across all locations -->
<div id="vib-sub-events" class="vib-sub-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex flex-wrap items-end gap-3 mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mr-auto">Project Events</h2>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="date" id="pve-from" onchange="loadProjectVibrationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="date" id="pve-to" onchange="loadProjectVibrationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Location</label>
<select id="pve-loc" onchange="_pveApplyAndRender()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">All locations</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Events</label>
<select id="pve-ft" onchange="loadProjectVibrationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">All</option>
<option value="false">Real Only</option>
<option value="true">FT Only</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
<select id="pve-limit" onchange="loadProjectVibrationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="250">250</option>
<option value="500" selected>500</option>
<option value="1000">1000</option>
</select>
</div>
<button onclick="clearProjectEventFilters()"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear
</button>
</div>
<div id="pve-container" class="overflow-x-auto">
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>
</div>
</div>
</div>
</div> </div>
<!-- Sound Tab --> <!-- Sound Tab -->
<div id="sound-tab" class="tab-panel hidden"> <div id="sound-tab" class="tab-panel hidden">
<!-- Sound module toolbar — per-module status + sound-only actions
(relocated here from the global project header). -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">Module status</span>
<select id="sound-module-status" onchange="setModuleStatus('sound_monitoring', this.value, this)"
class="text-sm font-medium bg-transparent border-0 focus:ring-0 text-gray-900 dark:text-white cursor-pointer py-0 pr-6">
<option value="active">Active</option>
<option value="on_hold">On hold</option>
<option value="completed">Completed</option>
</select>
</div>
<span id="sound-mode-chip" class="hidden items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium"></span>
<div class="ml-auto flex flex-wrap items-center gap-2">
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
class="px-3.5 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Combined Report
</a>
<button onclick="openNightReportModal()"
title="Last night's noise vs baseline, per location (FTP report pipeline)"
class="px-3.5 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm">
<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="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"></path>
</svg>
Night Report
</button>
<button onclick="openReportSettings('{{ project_id }}')"
title="Nightly report settings — schedule, baseline range, recipients"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
</div>
</div>
<!-- Sound sub-nav --> <!-- Sound sub-nav -->
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700"> <div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
<button id="sound-sub-locations-btn" onclick="switchSoundSubTab('locations')" <button id="sound-sub-locations-btn" onclick="switchSoundSubTab('locations')"
@@ -955,6 +1092,175 @@ function switchVibSubTab(name) {
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400'); btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
btn.classList.add('border-seismo-orange', 'text-seismo-orange'); btn.classList.add('border-seismo-orange', 'text-seismo-orange');
} }
// Lazy-load the Events table on first open.
if (name === 'events' && !_projectEventsLoaded) {
_projectEventsLoaded = true;
loadProjectVibrationEvents();
}
}
// ── Vibration Events sub-tab ─────────────────────────────────────────────
let _projectEventsLoaded = false;
let _pveAllEvents = []; // full fetched set (before client-side location filter)
let _pveTotal = 0; // project-wide count reported by the API
let _pveSort = { key: 'timestamp', dir: 'desc' };
function clearProjectEventFilters() {
document.getElementById('pve-from').value = '';
document.getElementById('pve-to').value = '';
document.getElementById('pve-ft').value = '';
const loc = document.getElementById('pve-loc'); if (loc) loc.value = '';
loadProjectVibrationEvents();
}
function _pveFmtPPV(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); }
function _pvePPVClass(v) {
if (v == null) return 'text-gray-400';
if (v >= 0.5) return 'text-red-500';
if (v >= 0.2) return 'text-amber-500';
return 'text-green-600 dark:text-green-400';
}
// Date range / FT / limit are server-side filters → re-fetch. Location and
// column sorting are applied client-side over the cached set so they're instant.
async function loadProjectVibrationEvents() {
const container = document.getElementById('pve-container');
if (!container) return;
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
const params = new URLSearchParams();
const from = document.getElementById('pve-from').value;
const to = document.getElementById('pve-to').value;
const ft = document.getElementById('pve-ft').value;
const limit = document.getElementById('pve-limit').value;
if (from) params.set('from_dt', from + ' 00:00:00');
if (to) params.set('to_dt', to + ' 23:59:59');
if (ft) params.set('false_trigger', ft);
params.set('limit', limit);
try {
const r = await fetch(`/api/projects/${projectId}/vibration-events?${params.toString()}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
_pveAllEvents = d.events || [];
_pveTotal = d.count || 0;
_pvePopulateLocations();
_pveApplyAndRender();
} catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${lsEsc(e.message)}</div>`;
}
}
// Rebuild the Location dropdown from whatever locations actually have events in
// the current fetch, preserving the operator's current selection if still valid.
function _pvePopulateLocations() {
const sel = document.getElementById('pve-loc');
if (!sel) return;
const prev = sel.value;
const seen = new Map();
_pveAllEvents.forEach(ev => {
if (ev.location_id && !seen.has(ev.location_id)) seen.set(ev.location_id, ev.location_name || ev.location_id);
});
const opts = ['<option value="">All locations</option>'];
[...seen.entries()]
.sort((a, b) => String(a[1]).localeCompare(String(b[1])))
.forEach(([id, name]) => opts.push(`<option value="${lsEsc(id)}">${lsEsc(name)}</option>`));
sel.innerHTML = opts.join('');
if (prev && seen.has(prev)) sel.value = prev;
}
function _pveSortBy(key) {
if (_pveSort.key === key) {
_pveSort.dir = (_pveSort.dir === 'asc') ? 'desc' : 'asc';
} else {
_pveSort.key = key;
_pveSort.dir = 'desc'; // numbers + dates most useful high→low first
}
_pveApplyAndRender();
}
const _PVE_NUM_KEYS = new Set(['tran_ppv', 'vert_ppv', 'long_ppv', 'peak_vector_sum', 'mic_ppv']);
const _PVE_STR_KEYS = new Set(['location_name', 'serial']);
function _pveApplyAndRender() {
const container = document.getElementById('pve-container');
if (!container) return;
const locId = document.getElementById('pve-loc')?.value || '';
let rows = locId ? _pveAllEvents.filter(ev => ev.location_id === locId) : _pveAllEvents.slice();
const { key, dir } = _pveSort;
const mul = dir === 'asc' ? 1 : -1;
rows.sort((a, b) => {
if (_PVE_NUM_KEYS.has(key)) {
const av = (a[key] == null) ? -Infinity : Number(a[key]);
const bv = (b[key] == null) ? -Infinity : Number(b[key]);
return (av - bv) * mul;
}
if (_PVE_STR_KEYS.has(key)) {
return String(a[key] || '').toLowerCase().localeCompare(String(b[key] || '').toLowerCase()) * mul;
}
// timestamp — ISO strings sort lexicographically
return String(a.timestamp || '').localeCompare(String(b.timestamp || '')) * mul;
});
_renderProjectEvents(rows, container, locId);
}
function _pveTh(label, key, align) {
const active = _pveSort.key === key;
const arrow = active ? (_pveSort.dir === 'asc' ? '▲' : '▼') : '<span class="opacity-0 group-hover:opacity-40"></span>';
const alignCls = align === 'right' ? 'text-right' : 'text-left';
return `<th onclick="_pveSortBy('${key}')"
class="group px-4 py-3 text-xs font-medium uppercase tracking-wider cursor-pointer select-none ${alignCls} ${active ? 'text-seismo-orange' : 'text-gray-700 dark:text-gray-300 hover:text-seismo-orange'}">
${label}<span class="ml-1 inline-block text-[10px] text-seismo-orange">${arrow}</span></th>`;
}
function _renderProjectEvents(events, container, locId) {
if (!events.length) {
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No events for the current filter.</div>';
return;
}
const rows = events.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const mic = ev.mic_ppv != null ? Number(ev.mic_ppv).toFixed(3) : '—';
const ft = ev.false_trigger
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>' : '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail('${lsEsc(ev.id)}')">
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">${lsEsc(ev.location_name || '—')}</td>
<td class="px-4 py-2.5 text-sm font-mono text-seismo-orange">
<a href="/unit/${lsEsc(ev.serial)}" class="hover:text-seismo-navy" onclick="event.stopPropagation()">${lsEsc(ev.serial)}</a>
</td>
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.tran_ppv)}">${_pveFmtPPV(ev.tran_ppv)}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.vert_ppv)}">${_pveFmtPPV(ev.vert_ppv)}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.long_ppv)}">${_pveFmtPPV(ev.long_ppv)}</td>
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_pvePPVClass(ev.peak_vector_sum)}">${_pveFmtPPV(ev.peak_vector_sum)}</td>
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
<td class="px-4 py-2.5 text-sm">${ft}</td>
</tr>`;
}).join('');
const scope = locId
? `Showing ${events.length.toLocaleString()} event${events.length === 1 ? '' : 's'} at this location`
: `Showing ${events.length.toLocaleString()} of ${_pveTotal.toLocaleString()} events`;
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 px-1 pb-2">${scope}</div>
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
${_pveTh('Timestamp', 'timestamp')}
${_pveTh('Location', 'location_name')}
${_pveTh('Serial', 'serial')}
${_pveTh('Tran', 'tran_ppv')}
${_pveTh('Vert', 'vert_ppv')}
${_pveTh('Long', 'long_ppv')}
${_pveTh('PVS', 'peak_vector_sum')}
${_pveTh('Mic', 'mic_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
} }
function switchSoundSubTab(name) { function switchSoundSubTab(name) {
@@ -971,6 +1277,41 @@ function switchSoundSubTab(name) {
} }
} }
// ── Per-module status (active / on_hold / completed) ─────────────────────
// Each module has its own lifecycle independent of the parent project, so the
// sound side can be "completed" while vibration keeps running.
const _MODULE_STATUS_LABEL = { active: 'Active', on_hold: 'On hold', completed: 'Completed' };
async function setModuleStatus(moduleType, status, selectEl) {
const prev = selectEl ? selectEl.getAttribute('data-prev') : null;
try {
const r = await fetch(`/api/projects/${projectId}/modules/${moduleType}/status`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!r.ok) throw new Error('HTTP ' + r.status);
if (selectEl) selectEl.setAttribute('data-prev', status);
// Refresh the header so the module chip's status badge updates.
if (window.htmx) htmx.ajax('GET', `/api/projects/${projectId}/header`, { target: '#project-header', swap: 'innerHTML' });
const name = (moduleType === 'sound_monitoring') ? 'Sound' : 'Vibration';
if (window.showToast) showToast(`${name} module marked ${_MODULE_STATUS_LABEL[status] || status}.`, 'success');
} catch (e) {
if (selectEl && prev) selectEl.value = prev; // revert the dropdown on failure
if (window.showToast) showToast('Could not update module status.', 'error');
else alert('Could not update module status.');
}
}
function _renderSoundModeChip(mode) {
const chip = document.getElementById('sound-mode-chip');
if (!chip) return;
const remote = mode === 'remote';
chip.classList.remove('hidden');
chip.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ' +
(remote ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300');
chip.textContent = remote ? 'Remote — data via FTP' : 'Manual — SD card upload';
}
// Load project details // Load project details
async function loadProjectDetails() { async function loadProjectDetails() {
try { try {
@@ -1000,6 +1341,14 @@ async function loadProjectDetails() {
if (modeRadio) modeRadio.checked = true; if (modeRadio) modeRadio.checked = true;
settingsUpdateModeStyles(); settingsUpdateModeStyles();
// Per-module status selects + the (sound-scoped) data-collection chip.
const ms = data.module_status || {};
const ssel = document.getElementById('sound-module-status');
if (ssel) { ssel.value = ms.sound_monitoring || 'active'; ssel.setAttribute('data-prev', ssel.value); }
const vsel = document.getElementById('vibration-module-status');
if (vsel) { vsel.value = ms.vibration_monitoring || 'active'; vsel.setAttribute('data-prev', vsel.value); }
_renderSoundModeChip(mode);
// Show/hide module tabs based on active modules // Show/hide module tabs based on active modules
const hasSoundModule = projectModules.includes('sound_monitoring'); const hasSoundModule = projectModules.includes('sound_monitoring');
const hasVibrationModule = projectModules.includes('vibration_monitoring'); const hasVibrationModule = projectModules.includes('vibration_monitoring');
@@ -1009,6 +1358,10 @@ async function loadProjectDetails() {
document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule); document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule);
document.getElementById('sound-settings-section')?.classList.toggle('hidden', !hasSoundModule); document.getElementById('sound-settings-section')?.classList.toggle('hidden', !hasSoundModule);
// Live monitoring section: only for sound projects (idempotent).
if (hasSoundModule) startLiveStats();
else document.getElementById('live-stats-section')?.classList.add('hidden');
// Within Sound: show Assigned Units + Schedules sub-tabs only for remote projects // Within Sound: show Assigned Units + Schedules sub-tabs only for remote projects
document.getElementById('sound-sub-units-btn')?.classList.toggle('hidden', !isRemote); document.getElementById('sound-sub-units-btn')?.classList.toggle('hidden', !isRemote);
document.getElementById('sound-sub-schedules-btn')?.classList.toggle('hidden', !isRemote); document.getElementById('sound-sub-schedules-btn')?.classList.toggle('hidden', !isRemote);
@@ -2075,6 +2428,196 @@ function submitUploadAll() {
} }
// Load project details on page load and restore active tab from URL hash // Load project details on page load and restore active tab from URL hash
// ── Live monitoring section (sound) ──────────────────────────────────────
// Self-contained: fetches /live-stats every 15s and renders a rollup + a
// live tile per NRL. Started from loadProjectDetails() once we know the
// project has the sound module, so vibration-only projects don't poll.
let liveStatsTimer = null;
const LS_LEVEL_AMBER = 55, LS_LEVEL_RED = 70;
function lsEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function lsNum(v) { const f = parseFloat(v); return isNaN(f) ? null : f; }
function lsFmtAgo(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 s + 's ago';
if (s < 3600) return Math.round(s / 60) + 'm ago';
if (s < 86400) return Math.round(s / 3600) + 'h ago';
return Math.round(s / 86400) + 'd ago';
}
// Headline Leq color, matched to the portal thresholds.
function lsLeqColor(leq, measuring) {
if (!measuring || leq == null) return 'text-gray-400 dark:text-gray-500';
if (leq >= LS_LEVEL_RED) return 'text-red-500';
if (leq >= LS_LEVEL_AMBER) return 'text-amber-500';
return 'text-green-500';
}
// Friendly labels for NL-43 battery / power-source codes (fall back to raw).
function lsBattery(code) {
return ({F:'Full', M:'Mid', L:'Low', D:'Dead', E:'Empty'})[code] || (code || '');
}
function lsPower(code) {
return ({I:'Battery', E:'External', U:'USB'})[code] || (code || '');
}
function lsRenderTile(loc) {
const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure';
const wedged = loc.connection_state === 'wedged';
const reachable = loc.is_reachable !== false; // null/absent → assume ok
const hasData = loc.measurement_state != null || loc.leq != null;
// Status badge
let badge;
if (!loc.unit_id) {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">No unit</span>';
} else if (wedged) {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>';
} else if (!reachable || !hasData) {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>';
} else if (measuring) {
badge = '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] bg-orange-100 dark:bg-orange-900/30 text-seismo-orange"><span class="relative flex h-1.5 w-1.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span><span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-seismo-orange"></span></span>Live</span>';
} else {
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>';
}
const leqNum = lsNum(loc.leq);
const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq;
const leqColor = lsLeqColor(leqNum, measuring);
// Health line: unit · last-seen · battery/power
const bits = [];
if (loc.unit_id) bits.push(`<span class="font-mono text-seismo-orange">${lsEsc(loc.unit_id)}</span>`);
if (hasData && loc.last_seen) bits.push(lsEsc(lsFmtAgo(loc.last_seen)));
if (hasData && (loc.battery_level || loc.power_source)) {
const b = lsBattery(loc.battery_level), p = lsPower(loc.power_source);
const low = loc.battery_level === 'L' || loc.battery_level === 'D' || loc.battery_level === 'E';
bits.push(`<span class="${low ? 'text-red-500' : ''}">${lsEsc([p, b].filter(Boolean).join(' · '))}</span>`);
}
const levels = (hasData)
? `<div class="mt-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums">
Lp ${lsEsc(loc.lp ?? '--')} &nbsp; Lmax ${lsEsc(loc.lmax ?? '--')}
</div>`
: '';
return `
<a href="/projects/${projectId}/nrl/${encodeURIComponent(loc.id)}"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 border border-transparent hover:border-seismo-orange hover:shadow-xl transition-all">
<div class="flex items-start justify-between gap-2">
<div class="font-semibold text-gray-900 dark:text-white truncate">${lsEsc(loc.name)}</div>
${badge}
</div>
<div class="mt-3 flex items-baseline gap-1.5">
<span class="text-4xl leading-none font-semibold tabular-nums ${leqColor}">${lsEsc(leqStr)}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">dB Leq</span>
</div>
${levels}
<div class="mt-2 text-[11px] text-gray-400 dark:text-gray-500 flex flex-wrap gap-x-2 gap-y-0.5">
${bits.join('<span class="opacity-40">·</span>')}
</div>
</a>`;
}
function lsRender(locations) {
const section = document.getElementById('live-stats-section');
if (!section) return;
if (!locations.length) { section.classList.add('hidden'); return; }
section.classList.remove('hidden');
// Rollup
let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null;
for (const l of locations) {
const measuring = l.measurement_state === 'Start' || l.measurement_state === 'Measure';
const hasData = l.measurement_state != null || l.leq != null;
if (measuring) {
live++;
const n = lsNum(l.leq);
if (n != null && (peak == null || n > peak)) { peak = n; peakStr = l.leq; peakLoc = l.name; }
} else if (!l.unit_id || !hasData || l.is_reachable === false) {
off++;
}
}
document.getElementById('ls-live').textContent = live;
document.getElementById('ls-offline').textContent = off;
const pw = document.getElementById('ls-loudest-wrap');
if (peak != null) {
pw.classList.remove('hidden');
document.getElementById('ls-loudest').textContent = peakStr;
document.getElementById('ls-loudest-loc').textContent = peakLoc;
} else { pw.classList.add('hidden'); }
document.getElementById('ls-tiles').innerHTML = locations.map(lsRenderTile).join('');
}
// Compact level-tinted pill classes for the inline NRL-card chips.
function lsInlineLevelPill(leq) {
if (leq == null) return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300';
if (leq >= LS_LEVEL_RED) return 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-300';
if (leq >= LS_LEVEL_AMBER) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300';
}
function lsInlineChipHtml(loc) {
if (!loc.unit_id) return ''; // no unit assigned → no chip
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ';
const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure';
const hasData = loc.measurement_state != null || loc.leq != null;
const reachable = loc.is_reachable !== false;
if (loc.connection_state === 'wedged')
return `<span class="${base}bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>`;
if (!reachable || !hasData)
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>`;
if (measuring) {
const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq;
return `<span class="${base}${lsInlineLevelPill(lsNum(loc.leq))}">`
+ `<span class="w-1.5 h-1.5 rounded-full bg-current"></span>${lsEsc(leqStr)} dB</span>`;
}
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>`;
}
// Paint the inline chips on the NRL list cards (Overview + Sound tab).
function lsPaintInline(locations) {
const byId = {};
for (const l of locations) byId[l.id] = l;
document.querySelectorAll('[data-loc-live]').forEach(el => {
const loc = byId[el.getAttribute('data-loc-live')];
const html = loc ? lsInlineChipHtml(loc) : '';
el.innerHTML = html;
el.classList.toggle('hidden', !html);
});
}
let lsLastData = [];
async function loadLiveStats() {
// Skip work while the tab is hidden in the background.
if (document.hidden) return;
try {
const r = await fetch(`/api/projects/${projectId}/live-stats`);
if (!r.ok) return;
const j = await r.json();
lsLastData = j.locations || [];
lsRender(lsLastData);
lsPaintInline(lsLastData);
} catch (e) { /* keep last render */ }
}
// The NRL list partial reloads via htmx (e.g. the 30s dashboard swap), which
// wipes the painted chips — repaint from the last poll as soon as it settles.
document.body.addEventListener('htmx:afterSwap', (e) => {
const id = (e.target && e.target.id) || '';
// Overview lists are now per-type (project-locations-sound/-vibration); the
// Sound tab uses sound-locations. Repaint chips whenever any of them swap.
if (id.startsWith('project-locations') || id === 'sound-locations') lsPaintInline(lsLastData);
});
function startLiveStats() {
if (liveStatsTimer) return; // already running
loadLiveStats();
liveStatsTimer = setInterval(loadLiveStats, 15000);
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadProjectDetails(); loadProjectDetails();
@@ -2189,4 +2732,15 @@ async function regeneratePassword() {
} catch (e) { paToast('Could not generate a password.'); } } catch (e) { paToast('Could not generate a password.'); }
} }
</script> </script>
<!-- Shared SFM event-detail modal, for the Vibration Events sub-tab rows. -->
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
<script>
// When an event's review (FT flag / notes) is saved in the modal, refresh
// the project events table so the FT badge updates without a full reload.
window.addEventListener('sfm-event-review-saved', () => {
if (_projectEventsLoaded) loadProjectVibrationEvents();
});
</script>
{% endblock %} {% endblock %}
+5 -4
View File
@@ -473,12 +473,12 @@
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label> <label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="datetime-local" id="ue-filter-from" onchange="loadUnitEvents()" <input type="date" id="ue-filter-from" onchange="loadUnitEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"> class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">To</label> <label class="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="datetime-local" id="ue-filter-to" onchange="loadUnitEvents()" <input type="date" id="ue-filter-to" onchange="loadUnitEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"> class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -2882,8 +2882,9 @@ async function loadUnitEvents() {
const ft = document.getElementById('ue-filter-ft').value; const ft = document.getElementById('ue-filter-ft').value;
const limit = document.getElementById('ue-filter-limit').value; const limit = document.getElementById('ue-filter-limit').value;
params.set('bucket', bucket); params.set('bucket', bucket);
if (from) params.set('from_dt', from.replace('T', ' ')); // Date inputs: From = start of that day, To = inclusive end of that day.
if (to) params.set('to_dt', to.replace('T', ' ')); if (from) params.set('from_dt', from + ' 00:00:00');
if (to) params.set('to_dt', to + ' 23:59:59');
if (ft) params.set('false_trigger', ft); if (ft) params.set('false_trigger', ft);
params.set('limit', limit); params.set('limit', limit);
+5 -4
View File
@@ -287,12 +287,12 @@
<div class="flex flex-wrap items-end gap-3"> <div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label> <label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="datetime-local" id="ev-filter-from" onchange="loadLocationEvents()" <input type="date" id="ev-filter-from" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"> class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">To</label> <label class="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="datetime-local" id="ev-filter-to" onchange="loadLocationEvents()" <input type="date" id="ev-filter-to" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"> class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -501,8 +501,9 @@ async function loadLocationEvents() {
const to = document.getElementById('ev-filter-to').value; const to = document.getElementById('ev-filter-to').value;
const ft = document.getElementById('ev-filter-ft').value; const ft = document.getElementById('ev-filter-ft').value;
const limit = document.getElementById('ev-filter-limit').value; const limit = document.getElementById('ev-filter-limit').value;
if (from) params.set('from_dt', from.replace('T', ' ')); // Date inputs: From = start of that day, To = inclusive end of that day.
if (to) params.set('to_dt', to.replace('T', ' ')); if (from) params.set('from_dt', from + ' 00:00:00');
if (to) params.set('to_dt', to + ' 23:59:59');
if (ft) params.set('false_trigger', ft); if (ft) params.set('false_trigger', ft);
params.set('limit', limit); params.set('limit', limit);
+26
View File
@@ -50,6 +50,20 @@ def _reset_portal_lockout():
yield yield
@pytest.fixture(autouse=True)
def _operator_auth_off_by_default(monkeypatch):
"""Pin the operator-auth gate OFF for every test, so the suite is deterministic
regardless of the container's OPERATOR_AUTH_ENABLED env (the dev container may
have it ON for manual testing). Tests that exercise the gate opt in via
wire_operator_auth(enabled=True), which overrides this within the test body."""
try:
import backend.operator_auth as _oa
monkeypatch.setattr(_oa, "OPERATOR_AUTH_ENABLED", False, raising=False)
except Exception:
pass
yield
def make_project(db_session, name=None, **kwargs): def make_project(db_session, name=None, **kwargs):
"""Insert and return a Project with a unique name.""" """Insert and return a Project with a unique name."""
p = models.Project( p = models.Project(
@@ -62,3 +76,15 @@ def make_project(db_session, name=None, **kwargs):
db_session.add(p) db_session.add(p)
db_session.commit() db_session.commit()
return p return p
def wire_operator_auth(monkeypatch, db_session, enabled=True):
"""Point the gate middleware's SessionLocal at the test engine and flip the
flag. The middleware opens its OWN session (it can't use the get_db override),
so it must read the same engine the test writes to."""
import backend.operator_auth as oa
from sqlalchemy.orm import sessionmaker
maker = sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False)
monkeypatch.setattr(oa, "SessionLocal", maker, raising=False)
monkeypatch.setattr(oa, "OPERATOR_AUTH_ENABLED", enabled, raising=False)
return oa
+44
View File
@@ -0,0 +1,44 @@
# tests/test_operator_admin_cli.py
from sqlalchemy.orm import sessionmaker
from backend.models import OperatorUser
from backend.auth_passwords import verify_password
import backend.operator_admin as cli
def _maker(db_session):
return sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False)
def test_seed_superadmin(db_session, monkeypatch):
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
cli.cmd_create_superadmin(email="brian@x.com", name="Brian", password="chosen-pw-1")
u = db_session.query(OperatorUser).filter_by(email="brian@x.com").first()
assert u.role == "superadmin"
assert u.must_change_password is False
assert verify_password("chosen-pw-1", u.password_hash)
def test_create_user_generates_temp(db_session, monkeypatch, capsys):
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
cli.cmd_create_user(email="dad@x.com", name="Dad", role="admin")
u = db_session.query(OperatorUser).filter_by(email="dad@x.com").first()
assert u.role == "admin" and u.must_change_password is True
assert "dad@x.com" in capsys.readouterr().out # prints the temp once
def test_reset_password_cli(db_session, monkeypatch):
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
cli.cmd_create_user(email="r@x.com", name="R", role="admin")
before = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash
cli.cmd_reset_password(email="r@x.com")
after = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash
assert before != after
def test_disable_enable_cli(db_session, monkeypatch):
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
cli.cmd_create_user(email="d@x.com", name="D", role="admin")
cli.cmd_set_active(email="d@x.com", active=False)
assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is False
cli.cmd_set_active(email="d@x.com", active=True)
assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is True
+89
View File
@@ -0,0 +1,89 @@
# tests/test_operator_authenticate.py
import time
from datetime import datetime
import pytest
from backend.operator_auth import (
authenticate, create_operator, reset_operator_password,
set_operator_active, set_operator_role, change_own_password, MAX_LOGIN_FAILURES,
)
from backend.auth_passwords import verify_password
from backend.models import OperatorUser
def test_create_operator_generates_temp_and_forces_change(db_session):
user, raw = create_operator(db_session, "Dad@X.com", "Dad", "admin")
assert user.email == "dad@x.com" # lowercased
assert user.must_change_password is True
assert verify_password(raw, user.password_hash)
def test_create_operator_with_explicit_password_no_forced_change(db_session):
user, raw = create_operator(db_session, "brian@x.com", "Brian", "superadmin", password="chosen-pw-123")
assert raw == "chosen-pw-123"
assert user.must_change_password is False
def test_create_operator_rejects_duplicate_and_bad_role(db_session):
create_operator(db_session, "a@x.com", "A", "admin")
with pytest.raises(ValueError):
create_operator(db_session, "A@x.com", "A2", "admin") # dup (case-insensitive)
with pytest.raises(ValueError):
create_operator(db_session, "b@x.com", "B", "wizard") # bad role
def test_authenticate_success(db_session):
user, raw = create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
got, status = authenticate(db_session, "OK@x.com", "rightpw-9")
assert status == "ok" and got.id == user.id
assert got.last_login_at is not None
assert got.failed_login_count == 0
def test_authenticate_wrong_password_counts(db_session):
create_operator(db_session, "wp@x.com", "Wp", "admin", password="rightpw-9")
got, status = authenticate(db_session, "wp@x.com", "nope")
assert got is None and status == "bad"
assert db_session.query(OperatorUser).filter_by(email="wp@x.com").first().failed_login_count == 1
def test_lockout_after_five_then_correct_password_refused(db_session):
create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9")
for _ in range(MAX_LOGIN_FAILURES):
authenticate(db_session, "lk@x.com", "nope")
got, status = authenticate(db_session, "lk@x.com", "rightpw-9") # correct, but locked
assert got is None and status == "locked"
def test_authenticate_unknown_email_is_bad_not_error(db_session):
got, status = authenticate(db_session, "ghost@x.com", "whatever")
assert got is None and status == "bad"
def test_reset_password_sets_new_hash_forces_change_and_bumps_sessions(db_session):
user, _ = create_operator(db_session, "r@x.com", "R", "admin", password="orig-pw-1")
before = user.sessions_valid_from
raw = reset_operator_password(db_session, user)
assert verify_password(raw, user.password_hash)
assert user.must_change_password is True
assert user.sessions_valid_from >= before
def test_change_own_password_clears_flag_and_bumps(db_session):
user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1")
user.must_change_password = True
db_session.commit()
new_iat = change_own_password(db_session, user, "brand-new-pw-2")
assert verify_password("brand-new-pw-2", user.password_hash)
assert user.must_change_password is False
assert user.sessions_valid_from == datetime.utcfromtimestamp(new_iat)
def test_set_active_and_role(db_session):
user, _ = create_operator(db_session, "s@x.com", "S", "admin", password="orig-pw-1")
set_operator_active(db_session, user, False)
assert user.active is False
set_operator_role(db_session, user, "superadmin")
assert user.role == "superadmin"
with pytest.raises(ValueError):
set_operator_role(db_session, user, "wizard")
+49
View File
@@ -0,0 +1,49 @@
# tests/test_operator_cookies.py
import time
import base64
import json
from backend.auth_cookies import sign, read
def test_sign_then_read_round_trips():
now = int(time.time())
raw = sign({"uid": "abc", "iat": now})
data = read(raw, max_age=3600)
assert data == {"uid": "abc", "iat": now}
def test_tampered_signature_is_rejected():
raw = sign({"uid": "abc", "iat": int(time.time())})
body, _sig = raw.rsplit(".", 1)
assert read(body + ".deadbeef", max_age=3600) is None
def test_tampered_body_is_rejected():
raw = sign({"uid": "abc", "iat": int(time.time())})
body, sig = raw.rsplit(".", 1)
forged = base64.urlsafe_b64encode(json.dumps({"uid": "evil", "iat": int(time.time())}).encode()).decode()
assert read(forged + "." + sig, max_age=3600) is None
def test_expired_by_iat_is_rejected():
raw = sign({"uid": "abc", "iat": int(time.time()) - 10_000})
assert read(raw, max_age=3600) is None
def test_garbage_input_is_none_not_raise():
assert read("not-a-cookie", max_age=3600) is None
assert read("", max_age=3600) is None
assert read(None, max_age=3600) is None
def test_wrong_secret_is_rejected(monkeypatch):
import backend.auth_cookies as ac
monkeypatch.setattr(ac, "SECRET_KEY", "secret-A")
raw = ac.sign({"uid": "x", "iat": int(time.time())})
monkeypatch.setattr(ac, "SECRET_KEY", "secret-B")
assert ac.read(raw, max_age=3600) is None
def test_future_dated_iat_is_rejected():
raw = sign({"uid": "x", "iat": int(time.time()) + 10_000})
assert read(raw, max_age=3600) is None
+76
View File
@@ -0,0 +1,76 @@
# tests/test_operator_gate.py
import uuid
from tests.conftest import wire_operator_auth
from backend.models import OperatorUser
from backend.operator_auth import make_operator_cookie, COOKIE_NAME
from backend.auth_passwords import hash_password
def _make_user(db, role="admin", **kw):
u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"),
display_name="U", password_hash=hash_password("pw"), role=role, **kw)
db.add(u)
db.commit()
return u
def test_flag_off_passes_everything(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=False)
assert client.get("/", follow_redirects=False).status_code == 200
def test_gated_html_redirects_to_login_when_unauth(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.get("/", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"].startswith("/login?next=")
def test_gated_api_returns_401_json_when_unauth(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.get("/api/status-snapshot", follow_redirects=False)
assert r.status_code == 401
def test_valid_session_passes(client, db_session, monkeypatch):
u = _make_user(db_session)
wire_operator_auth(monkeypatch, db_session, enabled=True)
client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id))
assert client.get("/", follow_redirects=False).status_code == 200
def test_must_change_password_user_routed_to_change_password(client, db_session, monkeypatch):
u = _make_user(db_session, must_change_password=True)
wire_operator_auth(monkeypatch, db_session, enabled=True)
client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id))
r = client.get("/", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/change-password"
def test_exempt_paths_pass_without_cookie(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
assert client.get("/health", follow_redirects=False).status_code == 200
def test_portal_paths_are_exempt(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
# /portal/p/<bad> hits the portal's own gate (403/404), never the operator login.
r = client.get("/portal/p/nope", follow_redirects=False)
assert r.status_code in (403, 404)
def test_must_change_user_on_api_gets_403_json_not_redirect(client, db_session, monkeypatch):
u = _make_user(db_session, must_change_password=True)
wire_operator_auth(monkeypatch, db_session, enabled=True)
client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id))
r = client.get("/api/status-snapshot", follow_redirects=False)
assert r.status_code == 403
assert r.json()["detail"] == "Password change required"
def test_options_preflight_passes_through_gate(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
# CORS preflight has no cookie; the gate must not 303/401 it.
r = client.options("/api/status-snapshot", follow_redirects=False)
assert r.status_code not in (303, 401)
+98
View File
@@ -0,0 +1,98 @@
# tests/test_operator_login.py
import uuid
from tests.conftest import wire_operator_auth
from backend.operator_auth import (
create_operator, make_operator_cookie, COOKIE_NAME, MAX_LOGIN_FAILURES,
)
def test_login_page_renders(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.get("/login")
assert r.status_code == 200
assert "password" in r.text.lower()
def test_login_success_sets_cookie_and_redirects(client, db_session, monkeypatch):
create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.post("/login", data={"email": "ok@x.com", "password": "rightpw-9"},
follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/"
assert f"{COOKIE_NAME}=" in r.headers.get("set-cookie", "")
def test_login_honors_next(client, db_session, monkeypatch):
create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.post("/login?next=/settings", data={"email": "ok@x.com", "password": "rightpw-9"},
follow_redirects=False)
assert r.headers["location"] == "/settings"
def test_login_wrong_password_no_cookie_generic_error(client, db_session, monkeypatch):
create_operator(db_session, "ok@x.com", "Ok", "admin", password="rightpw-9")
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.post("/login", data={"email": "ok@x.com", "password": "nope"},
follow_redirects=False)
assert r.status_code == 200
assert f"{COOKIE_NAME}=" not in r.headers.get("set-cookie", "")
assert "invalid" in r.text.lower()
def test_login_must_change_redirects_to_change_password(client, db_session, monkeypatch):
create_operator(db_session, "new@x.com", "New", "admin") # generated temp → must_change
from backend.models import OperatorUser
user = db_session.query(OperatorUser).filter_by(email="new@x.com").first()
from backend.auth_passwords import hash_password
user.password_hash = hash_password("temp-pw-1")
db_session.commit()
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.post("/login", data={"email": "new@x.com", "password": "temp-pw-1"},
follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/change-password"
def test_login_lockout_message_after_five(client, db_session, monkeypatch):
create_operator(db_session, "lk@x.com", "Lk", "admin", password="rightpw-9")
wire_operator_auth(monkeypatch, db_session, enabled=True)
for _ in range(MAX_LOGIN_FAILURES):
client.post("/login", data={"email": "lk@x.com", "password": "nope"}, follow_redirects=False)
r = client.post("/login", data={"email": "lk@x.com", "password": "rightpw-9"}, follow_redirects=False)
assert r.status_code == 200
assert "too many" in r.text.lower()
def test_logout_clears_cookie(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.get("/logout", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/login"
set_cookie = r.headers.get("set-cookie", "").lower()
assert COOKIE_NAME.lower() in set_cookie
assert 'max-age=0' in set_cookie or 'expires=thu, 01 jan 1970' in set_cookie
def test_change_password_self_service(client, db_session, monkeypatch):
user, _ = create_operator(db_session, "c@x.com", "C", "admin", password="orig-pw-1")
wire_operator_auth(monkeypatch, db_session, enabled=True)
client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id))
r = client.post("/change-password",
data={"current_password": "orig-pw-1", "new_password": "brand-new-2",
"confirm_password": "brand-new-2"}, follow_redirects=False)
assert r.status_code == 303
from backend.auth_passwords import verify_password
db_session.refresh(user)
assert verify_password("brand-new-2", user.password_hash)
assert user.must_change_password is False
def test_safe_next_blocks_open_redirect():
from backend.routers.operator_auth_routes import _safe_next
assert _safe_next("//evil.com") == "/"
assert _safe_next("/\\evil.com") == "/" # backslash authority bypass
assert _safe_next("https://evil.com") == "/"
assert _safe_next("") == "/"
assert _safe_next("/settings") == "/settings"
+42
View File
@@ -0,0 +1,42 @@
# tests/test_operator_machine_endpoints.py
from tests.conftest import wire_operator_auth
def test_machine_endpoints_not_blocked_by_gate(client, db_session, monkeypatch):
"""With the gate ON and no cookie, the LAN-only watcher endpoints must reach
their handlers (the gate must never silently break heartbeats). A handler may
return 422 for an empty body that still proves the gate let it through.
Note: /emitters/report uses a minimal valid body to avoid triggering the
app's validation_exception_handler (which calls await request.body() — a
known deadlock in Starlette 0.27 TestClient when the body is already
consumed). The gate behaviour is identical regardless of body validity.
"""
wire_operator_auth(monkeypatch, db_session, enabled=True)
r = client.post("/api/series3/heartbeat", json={}, follow_redirects=False)
assert r.status_code != 401 # gate would 401 an unauth /api/* route
assert r.status_code != 303
r = client.post("/api/series4/heartbeat", json={}, follow_redirects=False)
assert r.status_code not in (401, 303)
# /emitters/report is a sync endpoint with required Pydantic fields; supply a
# valid body so the validation_exception_handler (which awaits request.body())
# is never triggered — that handler deadlocks the Starlette 0.27 TestClient.
valid_report = {
"unit": "TEST001",
"unit_type": "series3",
"timestamp": "2024-01-01T00:00:00Z",
"file": "test.evt",
"status": "OK",
}
r = client.post("/emitters/report", json=valid_report, follow_redirects=False)
assert r.status_code != 303 # gate would 303 an unauth HTML route
def test_static_assets_exempt(client, db_session, monkeypatch):
wire_operator_auth(monkeypatch, db_session, enabled=True)
# /sw.js and /manifest.json are PWA assets clients fetch pre-login.
assert client.get("/sw.js", follow_redirects=False).status_code in (200, 404)
assert client.get("/sw.js", follow_redirects=False).status_code != 303
+36
View File
@@ -0,0 +1,36 @@
# tests/test_operator_model.py
import uuid
from backend.models import OperatorUser
from backend.operator_auth import role_at_least, _ROLE_RANK
def test_operator_user_defaults(db_session):
u = OperatorUser(id=str(uuid.uuid4()), email="a@x.com", display_name="A",
password_hash="h", role="admin")
db_session.add(u)
db_session.commit()
got = db_session.query(OperatorUser).filter_by(email="a@x.com").first()
assert got.active is True
assert got.must_change_password is False
assert got.failed_login_count == 0
assert got.locked_until is None
assert got.sessions_valid_from is not None
assert got.sessions_valid_from.microsecond == 0 # truncated to whole seconds
def test_email_is_unique(db_session):
for i in range(2):
db_session.add(OperatorUser(id=str(uuid.uuid4()), email="dup@x.com",
display_name="d", password_hash="h", role="admin"))
import pytest
with pytest.raises(Exception):
db_session.commit()
def test_role_ladder():
assert _ROLE_RANK == {"operator": 10, "admin": 20, "superadmin": 30}
assert role_at_least("superadmin", "admin") is True
assert role_at_least("admin", "admin") is True
assert role_at_least("admin", "superadmin") is False
assert role_at_least("operator", "admin") is False
assert role_at_least("nonsense", "admin") is False
+63
View File
@@ -0,0 +1,63 @@
# tests/test_operator_session.py
import time
import uuid
from datetime import datetime, timedelta
from types import SimpleNamespace
from backend.models import OperatorUser
from backend.operator_auth import (
make_operator_cookie, current_operator, COOKIE_NAME,
)
def _make_user(db, **kw):
u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"),
display_name="U", password_hash="h", role=kw.pop("role", "admin"), **kw)
db.add(u)
db.commit()
return u
def _req(cookie_value):
# current_operator only reads request.cookies — a stub is enough.
return SimpleNamespace(cookies={COOKIE_NAME: cookie_value} if cookie_value else {})
def test_valid_cookie_resolves_user(db_session):
u = _make_user(db_session)
cookie = make_operator_cookie(u.id)
assert current_operator(_req(cookie), db_session).id == u.id
def test_no_or_garbage_cookie_is_none(db_session):
assert current_operator(_req(None), db_session) is None
assert current_operator(_req("garbage"), db_session) is None
def test_inactive_user_is_none(db_session):
u = _make_user(db_session, active=False)
assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None
def test_locked_user_is_none(db_session):
u = _make_user(db_session, locked_until=datetime.utcnow() + timedelta(minutes=5))
assert current_operator(_req(make_operator_cookie(u.id)), db_session) is None
def test_cookie_older_than_sessions_valid_from_is_none(db_session):
u = _make_user(db_session)
old_iat = int(time.time()) - 1000
cookie = make_operator_cookie(u.id, iat=old_iat)
u.sessions_valid_from = datetime.utcnow()
db_session.commit()
assert current_operator(_req(cookie), db_session) is None
def test_cookie_minted_with_matching_iat_after_bump_still_valid(db_session):
# Guards the change-password race: bump sessions_valid_from to the new cookie's
# exact iat → that fresh cookie must remain valid.
u = _make_user(db_session)
new_iat = int(time.time())
u.sessions_valid_from = datetime.utcfromtimestamp(new_iat)
db_session.commit()
assert current_operator(_req(make_operator_cookie(u.id, iat=new_iat)), db_session).id == u.id
+132
View File
@@ -0,0 +1,132 @@
# tests/test_operator_users.py
import uuid
from tests.conftest import wire_operator_auth
from backend.operator_auth import create_operator, make_operator_cookie, COOKIE_NAME
from backend.models import OperatorUser
def _login_as(client, user):
client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id))
def test_admin_cannot_reach_user_management(client, db_session, monkeypatch):
admin, _ = create_operator(db_session, "admin@x.com", "Admin", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, admin)
assert client.get("/admin/users", follow_redirects=False).status_code == 403
def test_superadmin_sees_user_management(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
assert client.get("/admin/users", follow_redirects=False).status_code == 200
def test_superadmin_lists_users_json(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.get("/api/admin/users")
assert r.status_code == 200
emails = [u["email"] for u in r.json()["users"]]
assert "su@x.com" in emails
assert all("password_hash" not in u for u in r.json()["users"]) # never leak hashes
def test_create_user_returns_temp_once(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post("/api/admin/users",
json={"email": "dad@x.com", "name": "Dad", "role": "admin"})
assert r.status_code == 200
assert len(r.json()["password"]) >= 12
made = db_session.query(OperatorUser).filter_by(email="dad@x.com").first()
assert made.must_change_password is True
def test_reset_password_returns_temp_once(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{target.id}/reset-password")
assert r.status_code == 200 and len(r.json()["password"]) >= 12
db_session.refresh(target)
assert target.must_change_password is True
def test_disable_and_enable(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 200
db_session.refresh(target); assert target.active is False
assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 200
db_session.refresh(target); assert target.active is True
def test_change_role(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"})
assert r.status_code == 200
db_session.refresh(target); assert target.role == "superadmin"
def test_admin_cannot_reach_json_endpoints(client, db_session, monkeypatch):
admin, _ = create_operator(db_session, "a@x.com", "A", "admin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, admin)
assert client.get("/api/admin/users").status_code == 403
assert client.post("/api/admin/users", json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 403
assert client.post(f"/api/admin/users/{target.id}/reset-password").status_code == 403
assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 403
assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 403
assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"}).status_code == 403
def test_cannot_disable_own_account(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{su.id}/disable")
assert r.status_code == 400
db_session.refresh(su)
assert su.active is True
def test_cannot_change_own_role(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{su.id}/role", json={"role": "admin"})
assert r.status_code == 400
db_session.refresh(su)
assert su.role == "superadmin"
def test_deferred_operator_role_rejected_by_api(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
assert client.post("/api/admin/users", json={"email": "op@x.com", "name": "Op", "role": "operator"}).status_code == 400
assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "operator"}).status_code == 400
def test_admin_surface_404s_when_flag_off(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=False)
_login_as(client, su)
# With operator auth OFF, the management surface must not exist (404), even
# though require_role passes through — otherwise it'd be world-open.
assert client.get("/admin/users").status_code == 404
assert client.get("/api/admin/users").status_code == 404
assert client.post("/api/admin/users",
json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 404