47 Commits

Author SHA1 Message Date
serversdown 684a487203 docs: changelog [Unreleased] — add the client portal feature
Documents the read-only client portal under [Unreleased] alongside the SLM
live-monitoring work: per-client scoping + interim auth, live location view with
the auto-closing WS stream, locations overview map + rollup, the alerts
config→surface→24/7 track, operator sharing tools, the field-instrument design +
light/dark toggle, the security posture, and upgrade notes (migration, SECRET_KEY,
SLMM alert-engine pairing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 03:20:16 +00:00
serversdown 04cd6b9f24 docs(portal): security hardening backlog for the dedicated pass
Consolidates the deferred items (reverse proxy exposing only /portal/*, TLS,
SECRET_KEY, PORTAL_OPEN_LINKS off, M4 auth incl. the operator app + currently-
unauthenticated operator endpoints, and the smaller code-review items) into an
actionable checklist so the hardening session starts from a list, not a recall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:39:33 +00:00
serversdown fe7cf91488 fix(portal): pre-merge security hardening from code review
- PORTAL_OPEN_LINKS now defaults OFF — /portal/open/* is an unauthenticated,
  proxy-reachable session-minting path (and a linked project's open link grants
  the whole client's scope), so it must be explicitly enabled in dev.
- Session cookie: enforce server-side expiry (check iat vs COOKIE_MAX_AGE — was
  browser-only) and guard a non-dict signed body (was an uncaught AttributeError →
  500, reachable if SECRET_KEY is the insecure default).
- Escape operator-set strings (location/rule/event names) before innerHTML +
  Leaflet tooltips — they're client-facing, so a name with markup was stored XSS
  in the client's browser. Global esc() helper applied at every injection point.
- WS _scrub_frame drops a non-JSON frame instead of forwarding it raw; /history
  rows now whitelisted like the other scoped endpoints.
- Preview-client slug uses the full project id (an 8-char prefix could collide
  two projects onto one client).

Verified: cookie reader (fresh/expired/non-dict/missing-iat) + open-links default
off; templates parse; scoped scrubbing intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:40:52 +00:00
serversdown c1bc391ba2 feat(portal): show the client their active alert limits
New scoped GET /portal/api/location/{id}/thresholds returns the enabled alert
rules (scrubbed: name/metric/comparison/threshold/duration/schedule — no cooldown
or hysteresis internals). Location page renders an "Alert limits" panel above the
history, e.g. "Night noise · Leq above 65 dB for 60s · 22:00–07:00", hidden when
no limits are set. Gives the breach history context.

Verified: portal.py compiles; location script balances; template parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:28:57 +00:00
serversdown a81764d4bc style(portal): cool light background, keep the solid cards
Reverts the light-mode ground to a cool light (#eef2f9) with cool navy ink,
borders, and shadow — keeping the solid (opaque, defined) cards from the
un-ghosting pass so it's clean rather than dull. theme-color meta updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:46:04 +00:00
serversdown a555cb74dd style(portal): default to light theme
Light is now the default for new visitors/clients (was dark); the toggle still
flips to dark and the choice persists. Also fixed the mobile theme-color meta to
update the actual <meta> tag (was setting a no-op attribute on <html>) and use the
warm paper shade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:42:55 +00:00
serversdown 505c2e3ca5 style(portal): warm, solid light mode — paper bg + defined cards
Light mode was washed out. Switch the background to warm paper (#f7f5ef), make
panels solid white (no longer translucent/ghostly) with a warm hairline border
and a grounded two-layer shadow, and warm the text ink. Light-specific hover
shadow (the dark one is invisible on paper). Also fix two dark-only reds — the
alarm banner and active-event text now use var(--lvl-bad) so they read on both
themes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:34:31 +00:00
serversdown f760e81309 style(portal): live-console location page, polished access splash, light/dark toggle
- Location page rebuilt as a monitoring console: Leq hero readout (mono, level-
  colored, auto-flips with theme), instrument strip for Lp/Lmax/L1/L10, refined
  dark Chart.js (mono ticks, thin lines), panel-styled alert history, polished
  pause overlay. All live-stream/chart/alert JS hooks preserved.
- Access page → centered branded splash.
- Light/Dark toggle: CSS-variable theme system (structure + level/metric accent
  colors flip), header sun/moon button, localStorage + no-flash boot script,
  smooth body transition. On toggle, a 'portal-theme' event re-skins the Chart.js
  trace and swaps Leaflet tiles (CARTO dark <-> light) + recolors map dots.

All JS hook IDs intact (verified); both themes validated to parse + balance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:10:29 +00:00
serversdown 4839d14a22 style(portal): field-instrument redesign — shell + overview
A refined dark "field instrument" aesthetic for the client-facing portal:
- Type: Hanken Grotesk UI + IBM Plex Mono for readings (dB values feel like real
  instrumentation). Tabular numerals.
- Atmosphere: deep navy-black base with a navy/burgundy aurora and a faint fixed
  instrument grid; sticky blurred header with an animated signal-bars mark.
- Panel system (.panel/.panel-hover): translucent, hairline-lit, depth + hover
  lift. Pulsing live dot; staggered load reveal.
- Overview: mono Leq hero on each tile (colored by level when live), pill badges
  with the pulsing dot, rollup pills, dark CARTO map tiles, level-colored dots.

All live-data JS hook IDs preserved (verified). No backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:41:48 +00:00
serversdown fa7dc39e5e feat(portal): M2b-3 note — enabled alerts keep the device monitored 24/7
UI note on the SLM alerts card reflecting the SLMM keepalive coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:36:16 +00:00
serversdown 0914cf0a75 feat(portal): M2b-2 — surface alert state + breach history (internal + portal)
Internal (SLM detail page): live alarm-state badge in the Alerts header
(● N active / ✓ all clear), a History list of fired events (onset → clear, peak
dB, ack status) with an Ack button, refreshed every 20s. Reads the existing SLMM
/alerts/events + /ack via the proxy.

Portal (client, read-only, scoped): new GET /portal/api/location/{id}/events —
ownership-gated, returns a scrubbed projection (rule_name/metric/threshold/onset/
peak/clear/status only; no internal ids or ack-by) plus an `active` count. The
location page shows a red "Currently above threshold" banner when active and a
read-only breach history, polled every 20s. No ack on the client side.

Verified: portal.py compiles; both scripts balance; both templates parse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:02:56 +00:00
serversdown 29b974a1f7 feat(portal): M2b-1 — alert rule config UI on the SLM detail page
Adds an "Alerts" card to /slm/{id}: lists rules and a create/edit/delete form
(simple-first — "Alert when [Leq] is [above] [65] dB for [N] s", optional
time-of-day window + day picker, advanced hysteresis/cooldown collapsed). Talks
to the existing SLMM alert CRUD via the proxy (/api/slmm/{unit}/alerts/rules);
no SLMM changes. Rule changes invalidate the evaluator's cache server-side.

Verified: alerts script JS balances, slm_detail.html parses, and the TV proxy
forwards method + JSON body + query params for POST/PUT/DELETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:01:27 +00:00
serversdown bececafe78 feat(portal): plain no-token "open" links for dev feedback (PORTAL_OPEN_LINKS)
Adds a frictionless shareable link so anyone can open a project's client portal
during dev without minting/copying a magic token. GET /portal/open/{project_id}
(gated by PORTAL_OPEN_LINKS) provisions the client session and lands on /portal;
lives under /portal so it works through a proxy exposing only /portal/*.

The project page's "Copy client link" modal now leads with this Quick share link
(amber, host taken from window.location.origin so it always matches the host you
copied it from — no more LAN-vs-public foot-gun). The token-based generate/list/
revoke stays below for the eventual secure path.

PORTAL_OPEN_LINKS defaults ON for the prototype (whole app is open anyway) and logs
a warning; set =false before real clients. The get_current_client seam is
untouched, so M4 auth still layers in front of the same routes regardless.

Verified: compiles, share script balances, detail.html parses, flag default
on / =false off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:26:37 +00:00
serversdown 2da9493cb5 feat(portal): "Copy client link" — generate/copy/revoke shareable links from the project page
No-CLI way to get a real shareable magic link (/portal/enter/<token>) for a
project's client. Project page gets a "Copy client link" button next to the
preview; opens a modal that lists active links (with revoke), generates a fresh
one, and copies it to the clipboard.

Backend (operator, internal /projects/*):
- POST /projects/{id}/portal-link  -> mint a fresh token, return the full URL
  (built from request.base_url so it uses the operator's host).
- GET  /projects/{id}/portal-links -> list active links (label/created/last-used).
- POST /projects/{id}/portal-link/{tid}/revoke -> revoke one (scoped to the
  project's client).

Refactor: split ensure_project_client() + mint_link_token() out of
provision_preview_session() so minting a shareable link and the preview cookie
share one provisioning path.

Verified: ensure/mint persistence across commits + sessions, minted link resolves,
token stored hashed, second mint = distinct active link (4/4); compiles; share
script balances; detail.html parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:11:34 +00:00
serversdown b908f394ed feat(portal): M2a — live status map + status rollup on the overview
Reuses the existing per-location /live fetch (no backend change):
- Map dots recolor live by current level (green/amber/red bands, grey when
  not measuring/offline) and the tooltip shows the live Leq. Bands are
  placeholders until M2 alert thresholds drive the color.
- Status rollup header: total locations, # live vs offline, and a "Loudest now"
  Leq callout. Aggregated each 15s refresh.

Refactored the refresh into refreshAll() (Promise.all over loadTile -> updateRollup);
loadTile now also feeds liveState + recolors the matching map dot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:05:09 +00:00
serversdown 5455d3a931 style(portal): overview map uses dot markers, matching the internal project map
Swap Leaflet's default teardrop pins for L.circleMarker (radius 8, seismo-orange
fill, white border) + a name tooltip, same as partials/projects/location_map.html.
Also disables scroll-wheel zoom to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:40:17 +00:00
serversdown b971d19068 feat(portal): tile headline is Leq, not Lp; note live-map for M2
Lp (instantaneous) twitches every reading and makes a poor at-a-glance headline;
Leq (energy-average) is the stable, standard sound-monitoring/compliance metric.
Overview tiles now lead with Leq. Design doc: live project map (status-colored
pins + current-reading popups) recorded as an M2 item; headline-metric rationale
noted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:33:42 +00:00
serversdown 0103917870 feat(portal): live ~1Hz WS stream with auto-close (visibility + idle cap)
The portal location view is now genuinely live, not a 15s poll. Scoped WS endpoint
/portal/api/location/{id}/stream: authenticates via the session cookie, enforces
ownership (resolve_client_location), then bridges the unit's shared SLMM /monitor
fan-out feed to the browser — a viewer is just one more subscriber, no extra
device connection. Frames are scrubbed to the portal whitelist (drops unit_id,
raw_payload, counter, lmin) before reaching the client.

location.html: cache prefill for instant first paint, then upgrades to the live
socket (cards tick ~1Hz, chart scrolls). Auto-close so an abandoned tab can't pin
the device at 1Hz polling (~8x cellular data):
- closes when the tab is hidden, reopens when visible (Page Visibility) — the main
  guard;
- hard 15-min cap -> "Live paused — click to resume" overlay.

Refactor: client_from_cookie() extracted from get_current_client so the WS handler
(no Request-based Depends) can auth the same way.

Verified: scrub drops internal fields / keeps metrics + heartbeat (7/7), auth
refactor (3/3), portal compiles, location.html JS balances + parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:16:32 +00:00
serversdown 3fc20e104a feat(portal): one-click "View client portal" preview from the project page
Adds a "View client portal" button on the project detail page that opens the
client portal scoped to that project — no CLI. GET /projects/{id}/portal-preview
auto-provisions a client + access token for the project (provision_preview_session)
and seals a portal session cookie, then redirects to /portal.

- Reuses the project's linked client if it has one; otherwise creates/reuses a
  per-project 'preview-<id>' client. Only sets project.client_id when unset, so it
  never clobbers a real client link. Idempotent — repeat clicks reuse the same
  client/token.
- Lives under /projects (not /portal), so a future public proxy exposing only
  /portal/* won't expose this operator shortcut.

Verified: provisioning (unlinked creates+links, idempotent, linked-no-clobber) 7/7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:18:06 +00:00
serversdown 2031681d0f docs(portal): add "Going to prod" checklist (migration, SECRET_KEY, exposure)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:01:58 +00:00
serversdown 1cf80ea7ea fix(portal): portal_admin.py runnable as a script, not just -m
`python3 backend/portal_admin.py` set sys.path[0] to backend/, hiding the
`backend` package and breaking `from backend.database import ...`. Insert the
project root on sys.path so the documented script invocation works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:16:30 +00:00
serversdown 26b4b1e7e4 feat(portal): M1 admin CLI — create client, link projects, mint/revoke links
backend/portal_admin.py (run in-container): create-client, link-project (by id/
number/name -> sets Project.client_id), mint-link (prints the full magic URL once,
stores only the hash), list, revoke. PORTAL_BASE_URL controls the printed link base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:43:28 +00:00
serversdown d3e221b6b1 feat(portal): M1 pages — locations overview + read-only live location view
/portal overview: client's active sound locations as live tiles (current Lp +
Live/Stopped badge + "updated Xm ago", polled from the scoped cache every 15s)
plus a Leaflet map of locations with coordinates. /portal/location/{id}: 404-gated
read-only live panel — Lp/Leq/Lmax/L1/L10 cards + a 4-line Chart.js trace
(backfilled from /history) + measuring/freshness badge. Cache-only, 15s poll, no
device controls, no refresh-from-device. _client_locations() feeds the overview.

Verified: portal.py compiles; both inline scripts balance; all four portal
templates parse in Jinja2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:41:13 +00:00
serversdown 9f40210057 feat(portal): M1 scoping gate + scoped cache endpoints
resolve_client_location() enforces ownership (sound location in one of the
client's active projects) and 404s everything else — same response for missing
and not-yours, so location existence never leaks. active_unit_for_location()
resolves the currently-assigned SLM.

Scoped GET /portal/api/location/{id}/live and /history: gate -> resolve unit ->
read SLMM cache (never the device). /live returns a SCRUBBED projection (sound
metrics + run state only; no battery/SD/raw_payload). Both degrade gracefully
when there's no device or SLMM is down.

Verified: ownership gate (owns / other-client / vibration / deleted-project /
removed / nonexistent) + active-vs-completed unit resolution — 8/8 on a temp DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:38:16 +00:00
serversdown 6c048a9c30 feat(portal): M1 auth gate — signed magic-URL session + get_current_client
backend/portal_auth.py: stdlib HMAC-signed session cookie carrying the access-
token id (re-validated against the DB each request, so revoke kills live
sessions), hash_token, resolve_token, and the get_current_client dependency
(raises PortalAuthError). SECRET_KEY env (insecure dev default + warning).

routers/portal.py: /portal/enter/{token} mints the cookie -> /portal; /logout;
/access; /portal home stub. main.py registers the router + a PortalAuthError
handler (HTML access page for pages, 401 JSON for /portal/api/*).

Portal shell templates (base, access_required, overview stub), branded dark.

Verified: cookie round-trip + tamper/garbage rejection, token resolution
(valid/bad), get_current_client (valid/no-cookie/revoked) — 8/8 against a temp DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:36:09 +00:00
serversdown 80a8470b55 feat(portal): M1 data model — Client, ClientAccessToken, Project.client_id
Client (customer org), ClientAccessToken (interim hashed magic-URL gate), and an
authoritative Project.client_id FK (client_name kept for display). New tables
auto-create via create_all; migrate_add_client_portal.py adds projects.client_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:32:09 +00:00
serversdown a64c9ced65 docs: client portal design + milestone plan (M1 live view → M4 full auth)
Read-only, client-scoped portal inside Terra-View (/portal/*), reusing cached
SLMM reads. Data chain Client -> Project.client_id -> MonitoringLocation ->
active UnitAssignment -> unit_id -> SLMM cache. Auth is a swappable
get_current_client gate; M1-M3 ride an interim signed "magic URL", M4 replaces
the backing. Milestones: M1 live view, M2 dashboard+alerts, M3 reports, M4 auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:29:14 +00:00
serversdown 2e832708f3 docs: changelog [Unreleased] — SLM live monitoring (fan-out feed + cache-first reads)
Captures the whole feat/slm-live-monitor effort (12 commits): fan-out /monitor
feed consumption, L1/L10 chart lines, live-chart backfill, cache-populated live
panel with measuring/freshness, per-unit + panel refresh, admin keepalive toggle,
dashboard/command-center cache reads, and the dispatchEvent/CancelledError/
freshness fixes. Targets 0.14.0; Upgrade Notes flag the paired SLMM `dev` build +
its migrations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:17:10 +00:00
serversdown 5e3645e229 fix(slm): show real cache freshness in device list + draw L1/L10 on card chart
1. "No recent check-in" was always shown because the row's last-check text read
   unit.slm_last_check (a Terra-View roster field the monitor never updates),
   while the live freshness lives in SLMM's cached NL43Status.last_seen. Carry
   that last_seen onto the unit (unit.cache_last_seen) and display it (falling
   back to slm_last_check). Also treat "Measure" as Measuring in the badge, to
   match the panel and the cache's MEASURING_STATES.

2. The dashboard card chart only had Lp + Leq datasets, so L1/L10 never drew even
   though the cards showed them. Add L1 (purple) and L10 (orange) datasets and
   feed ln1/ln2 in both the /history backfill and the live /monitor frames.
   Percentiles parse via numOrNull so a missing "-.-" leaves a gap (spanGaps)
   instead of dropping the line to 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:52:57 +00:00
serversdown 88f258d1c7 chore: rename terra-view container for clarity. 2026-06-10 19:45:55 +00:00
serversdown 711ef41e5f feat(slm): auto-populate live panel from cache, per-unit refresh, fix badge overlap
Live Measurements panel no longer sits blank until you click Start Live Stream:
- On open it fills the KPI cards from the cached /status snapshot (lp/leq/lmax/
  L1/L10) and backfills the chart from the /history DOD trail — both pure cache
  reads, no device hit.
- Shows measuring state (● Measuring / ■ Stopped) and a freshness stamp
  ("as of 2:14 PM (12m ago)") that turns amber + "cached" when stale, so a cached
  value is never mistaken for a live reading.
- Polls the cache every 15s while open so the cards stay current without opening
  a device stream; Start Live Stream takes over (and no longer wipes the
  backfilled trail). Chart cap raised 60 -> 600 so the 2h backfill isn't truncated.

Refresh buttons (on-demand, user-initiated single device read via GET /live,
which also updates the cache):
- one per device row in the list, and one in the panel header. Spinner while in
  flight; toast on success/failure; reloads the list so badges + last-check update.

Layout fix: the status badge (Measuring/Active/Idle/Benched) was rendered at the
top-right of the card, colliding with the absolutely-positioned chart/gear icons.
Moved it to the bottom meta row next to "Last check", padded the card content
clear of the action icons, and added the refresh icon to that group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:59:42 +00:00
serversdown e27aef33ac fix(slm): guard htmx.trigger so deploy/bench doesn't throw on pages without #slm-list
toggleSLMDeployed() and the save-config success path both called
htmx.trigger('#slm-list', 'load') guarded only by `typeof htmx !== 'undefined'`.
No page actually has a #slm-list element, so htmx resolved the selector to null
and called null.dispatchEvent(...) -> "can't access property dispatchEvent, e is
null". The deploy POST had already succeeded and the green success message had
already rendered, so the user saw both "Unit marked as deployed." and a red
error. Guard the trigger on the element existing so it's a harmless no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:34:15 +00:00
serversdown 170dedb138 perf(slm): command center loads from cached status, no device pings
get_live_view fired two device calls on every command-center load:
/measurement-state (sends Measure?) and /live (fresh DOD read) — competing
with the monitor's DOD polling. Both are now redundant: the keepalive monitor
keeps NL43Status fresh (~1.3s) and the live-stream WS handles ongoing updates.

Read the cached /status once instead (no device call); derive is_measuring
from measurement_state. Command center opens instantly without poking the
device. (Relies on monitor_start_time now being in /status.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:57:45 +00:00
serversdown d92d01dc56 fix(slm): dashboard status from SLMM's cached roster, not a device call
"No recent check-in" read a roster field (slm_last_check) that nothing
stamps, and the live-status fetch hit /measurement-state — which sends
Measure? to the DEVICE every refresh, competing with DOD polling.

Now read SLMM's /roster once: it carries each unit's cached NL43Status
(last_seen, measurement_state) — a cache read, no device call. is_recent is
derived from last_seen (advances only on a successful monitor poll, so
staleness == not being reached) within 5 min, for all non-retired units
(benched units can still be monitored). Net: fewer device calls AND the
dashboard reflects the live monitor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:27:01 +00:00
serversdown 17a1a83bdf feat(slm): point dashboard live tile at /monitor too
Finishes the live-view pivot: the SLM dashboard's live-chart tile now uses
the fan-out /monitor feed (multi-viewer, L1/L10) instead of the DRD /stream,
and skips heartbeat / unreachable frames so they don't blank the metrics or
spike the chart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:29:50 +00:00
serversdown f5e93d5612 feat(slm): backfill the live chart from the DOD trail on open
On opening the live view, fetch GET /api/slmm/{unit}/history?hours=2 and
seed the chart with the recent trend BEFORE connecting the live socket, so
it opens with context instead of blank. Live frames then append in order.

- backfillChart() populates all four series (Lp/Leq/L1/L10) from the trail.
- initLiveDataStream is async and awaits the backfill before opening the WS.
- Chart rolling window raised 60 -> 600 points so the ~2h backfill (1/min)
  isn't immediately shifted out.
- Trail timestamps are naive UTC -> append 'Z' so they localize consistently
  with the live frames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:00:41 +00:00
serversdown bdc91177e2 feat(admin): per-unit live-monitoring (keepalive) toggle on /admin/slmm
Adds a "Live Monitoring (keepalive)" card listing each SLMM device with its
monitor_enabled state and an Enable/Disable toggle. Reads from /api/slmm/roster
(now includes monitor_enabled) and POSTs to /api/slmm/{unit}/monitor/{start,stop},
which persist the flag in SLMM (survives restarts; auto-started on boot). Shows a
reachability dot + 24/7 ON/OFF badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:28:55 +00:00
serversdown 3b818dcd97 fix(slm): stop monitor proxy leaking CancelledError on stream stop
The /monitor WS proxy cancelled its sibling task on disconnect but then
`except Exception` failed to swallow the resulting CancelledError (a
BaseException), so stopping the stream raised "Exception in ASGI
application". It also only awaited the pending task, leaving the done
task's WebSocketDisconnect unretrieved ("Task exception was never
retrieved"). Await all tasks and catch (CancelledError, Exception).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:12:34 +00:00
serversdown 61b144efd2 feat(slm): plot L1/L10 lines on the live chart
The L1/L10 cards populated, but the chart only had Lp + Leq datasets, so
the percentiles weren't drawn. Add L1 (violet) and L10 (amber) lines —
pushed/shifted/cleared alongside Lp/Leq — so the chart shows all four.

(Legend labels are hardcoded L1/L10, matching the default percentile slots;
dynamic ln1_label/ln2_label on the chart is a follow-up if a job reconfigures
the device's Ln slots.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:07:42 +00:00
serversdown c56b7f6c99 feat(slm): wire unit live view to the /monitor fan-out feed
The SLM live view now consumes SLMM's shared DOD /monitor feed instead of
the per-client DRD /stream. This fixes the single-connection contention
(many viewers share one device feed) and finally puts L1/L10 in the live
chart (DRD couldn't carry percentiles).

- New WS proxy handler /api/slmm/{unit}/monitor -> SLMM /api/nl43/{unit}/monitor.
  Uses asyncio.wait(FIRST_COMPLETED) + cancel-sibling instead of gather(), so
  it doesn't leave a task sending into a closed socket ("Unexpected ASGI
  message after close").
- Live view JS points at /monitor; onmessage reflects feed_status and ignores
  heartbeat / unreachable frames so they don't blank the cards or zero-spike
  the chart. Adds a small Live/Device-offline badge.

Still on the old /live (DRD): the dashboard live tile (sound_level_meters.html)
— next slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:13:17 +00:00
serversdown 08fec696f1 fix(slm): don't blank L1/L10 on percentile-less live-stream frames
The DRD stream carries Lp/Leq/Lmax but not the Ln percentiles (those come
from DOD polling), so updateLiveMetrics/updateDashboardMetrics were
overwriting the DOD-sourced L1/L10 values with '--' on every stream frame.
Guard the value updates on `data.lnN != null` so a frame without the key
leaves the existing value intact — mirrors the existing label guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:56:29 +00:00
serversdown 7f561c2c9d feat(slm): replace Lmin/Lpeak with configurable Ln1/Ln2 percentile slots
Live SLM display (dashboard + unit detail) now shows two configurable
percentile slots instead of Lmin/Lpeak. Values come from `ln1`/`ln2`;
labels come from `ln1_label`/`ln2_label` (default L1/L10), so a future
job can reconfigure the device's Ln slots to any percentile without a
Terra-View redeploy.

Contract for SLMM: emit ln1/ln2 (+ optional ln1_label/ln2_label) in both
the /status data dict and the DRD stream payload. No Terra-View Python
changes needed — proxy WS and current_status are transparent passthroughs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:31:52 +00:00
serversdown 38f2c751b8 version bump to 0.13.3 2026-06-05 06:37:09 +00:00
serversdown 78d72431b3 fix: add schedule to requirements 2026-06-05 04:22:50 +00:00
serversdown 6c41ccf1bd feat: add calibration sync system. 2026-06-04 18:52:22 +00:00
serversdown 56bd3041cf feat(dashboard): clarify the fleet status card and swap map locations to project monitoring location coords.
feat: Location no longer assigned directly to unit, locations and coords are assigned to location only, unit only is deployed or benched.
2026-06-01 22:01:38 +00:00
serversdown 623ef648b7 release: v0.13.2 — PWA cache fix so mobile gets the v0.13.x modal
Mobile operators were never seeing the inline PDF preview, .TXT
download, or Review form that v0.13.0 added — every feature was
working on desktop browsers but invisible in the PWA.

Root cause: backend/static/sw.js had CACHE_VERSION = 'v1', unchanged
since v0.12.x.  The activate handler deletes any cache not matching
CACHE_VERSION, so without a bump the stale sfm-static-v1 cache (with
the pre-v0.13.0 event-modal.js) stayed authoritative.  cacheFirst
strategy served it forever; mobile users effectively saw the v0.12.x
modal regardless of how many times we rebuilt the image.

Fix:
- CACHE_VERSION bumped to 'v0.13.2' (matches backend/main.py VERSION).
  Comment in sw.js documents the convention: any release touching a
  static asset must bump this string.
- event-modal.js added to the precache list so its lifecycle is
  explicitly tied to the SW version bump (installed fresh on activate
  rather than landing via the cacheFirst-then-cached pattern).

Mobile users get the new modal on next page nav: SW update check
picks up the bumped sw.js, skipWaiting installs it, activate evicts
the v1 caches, controllerchange fires, page reloads, fresh
event-modal.js loads.  Worst case ~1h delay from
registration.update() interval; operators can force-refresh by
closing + reopening the PWA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:11:07 +00:00
41 changed files with 3833 additions and 331 deletions
+149
View File
@@ -5,6 +5,155 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
SLM live monitoring — fan-out feed + cache-first reads. Targets **0.14.0**. The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed. This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand. Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.
### Added
- **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
- **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0.
- **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
- **Live Measurements panel auto-populates from cache.** Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit. Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale). Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
- **Refresh buttons** — one per device-list row, one in the panel header. On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.
### Changed
- **Dashboard device list + command center read SLMM's cache, not the device.** `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load. This stops dashboard loads from stealing the device's single connection from the live monitor. The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
- **Device-list freshness reflects real monitoring.** The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates. The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.
### Fixed
- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.** `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`. The deploy POST had already succeeded, so the operator saw both the green success **and** a red error. Both call sites now guard on the element existing (`slm_settings_modal.html`).
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.
### Upgrade Notes
Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.
```bash
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py
# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
```
The two builds must ship **together**. Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.
---
### Client portal *(new — read-only client-facing view)*
A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
locations, live. Built inside Terra-View (no new service), reusing the cached
SLMM feed; every route resolves the client through one swappable
`get_current_client` gate, so the interim magic/open-link auth can be replaced
(M4) without touching routes or templates. Strictly read-only — no device control.
#### Added
- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a
`Project.client_id` FK. A signed (HMAC) session cookie carries the access-token
id, re-validated against the DB each request (revoke kills live sessions, with
server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a
dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream
**auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
- **Locations overview.** Live status map (level-colored dots, dark/light CARTO
tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the
headline metric.
- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail
page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
in the portal; enabling a rule pins that device's monitor on so alerts evaluate
round-the-clock.
- **Operator sharing tools.** A **"View client portal"** preview button and a
**"Copy client link"** modal (mint / list / revoke magic links) on the project
page, plus a `backend/portal_admin.py` CLI.
- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI +
IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
**light/dark toggle** (light default, persisted, no-flash).
#### Security
- All scoping enforced server-side (404-not-403, no existence leak); client
endpoints return **scrubbed** projections (no device-health/internal ids); WS
frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
Pre-merge code review hardened cookie expiry, open-links default, and the slug
collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".
#### Upgrade Notes
- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
(adds `projects.client_id`; the `clients` / `client_access_tokens` tables
auto-create).
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
and keep **`PORTAL_OPEN_LINKS=false`** there.
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
cooldown + keepalive coupling) — same build pairing as above.
---
## [0.13.3] - 2026-06-05
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
### Added
- **Calibration sync service** (`backend/services/calibration_sync.py`). Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`. Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
- **Conflict rule: events-as-truth, manual wins when newer.** Three outcomes per unit:
- `already_in_sync` — stored date already matches the event's calibration date.
- `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved. Only a future event can supersede it.
- `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`). Started in `main.py`'s startup hook, stopped on shutdown. Does not run on boot — first sync after a server start fires at the next 03:15.
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`. Powers the Settings button.
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows. Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab). Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist. Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.
### Fixed
- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**. The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it. Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.
### Upgrade Notes
No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist. Rebuild only:
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```
After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.
---
## [0.13.2] - 2026-05-30
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
### Fixed
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
### What mobile users will see after deploy
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
---
## [0.13.1] - 2026-05-29 ## [0.13.1] - 2026-05-29
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time. Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
+1 -1
View File
@@ -1,4 +1,4 @@
# Terra-View v0.13.1 # Terra-View v0.13.3
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features
+109 -3
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.13.1" VERSION = "0.13.3"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":
@@ -66,6 +66,21 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Use shared templates configuration with timezone filters # Use shared templates configuration with timezone filters
from backend.templates_config import templates from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client).
from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS
@app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError):
if request.url.path.startswith("/portal/api"):
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "required"},
status_code=401,
)
# Add custom context processor to inject environment variable into all templates # Add custom context processor to inject environment variable into all templates
@app.middleware("http") @app.middleware("http")
async def add_environment_to_context(request: Request, call_next): async def add_environment_to_context(request: Request, call_next):
@@ -97,6 +112,10 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router) app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router) app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router) app.include_router(seismo_dashboard.router)
# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md
from backend.routers import portal
app.include_router(portal.router)
app.include_router(sfm.router) app.include_router(sfm.router)
app.include_router(modem_dashboard.router) app.include_router(modem_dashboard.router)
@@ -144,9 +163,14 @@ app.include_router(fleet_calendar.router)
from backend.routers import deployments from backend.routers import deployments
app.include_router(deployments.router) app.include_router(deployments.router)
# Calibration sync router (SFM-driven cal date updates)
from backend.routers import calibration
app.include_router(calibration.router)
# Start scheduler service and device status monitor on application startup # Start scheduler service and device status monitor on application startup
from backend.services.scheduler import start_scheduler, stop_scheduler from backend.services.scheduler import start_scheduler, stop_scheduler
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
from backend.services.calibration_sync import get_calibration_sync_scheduler
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
@@ -159,6 +183,10 @@ async def startup_event():
await start_device_status_monitor() await start_device_status_monitor()
logger.info("Device status monitor started") logger.info("Device status monitor started")
logger.info("Starting calibration sync scheduler...")
get_calibration_sync_scheduler().start()
logger.info("Calibration sync scheduler started")
@app.on_event("shutdown") @app.on_event("shutdown")
def shutdown_event(): def shutdown_event():
"""Clean up services on app shutdown""" """Clean up services on app shutdown"""
@@ -170,6 +198,10 @@ def shutdown_event():
stop_scheduler() stop_scheduler()
logger.info("Scheduler service stopped") logger.info("Scheduler service stopped")
logger.info("Stopping calibration sync scheduler...")
get_calibration_sync_scheduler().stop()
logger.info("Calibration sync scheduler stopped")
# Legacy routes from the original backend # Legacy routes from the original backend
from backend import routes as legacy_routes from backend import routes as legacy_routes
@@ -377,10 +409,84 @@ async def project_detail_page(request: Request, project_id: str):
"""Project detail dashboard""" """Project detail dashboard"""
return templates.TemplateResponse("projects/detail.html", { return templates.TemplateResponse("projects/detail.html", {
"request": request, "request": request,
"project_id": project_id "project_id": project_id,
"portal_open_links": PORTAL_OPEN_LINKS,
}) })
@app.get("/projects/{project_id}/portal-preview")
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
"""Operator testing shortcut: log into the client portal scoped to this project
(auto-provisioning a client/link if needed), no CLI. Lives under /projects (not
/portal), so a public proxy that exposes only /portal/* won't expose this."""
from backend.models import Project
from backend.portal_auth import (
provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
)
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
token_id = provision_preview_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
return resp
@app.post("/projects/{project_id}/portal-link")
async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Mint a fresh shareable client link for this project's client. Returns the
full /portal/enter/<token> URL (shown once). Operator-only (internal app)."""
from backend.models import Project
from backend.portal_auth import ensure_project_client, mint_link_token
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
client = ensure_project_client(project, db)
raw = mint_link_token(client, db, label="shared link")
url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}"
return {"url": url, "client_name": client.name}
@app.get("/projects/{project_id}/portal-links")
async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)):
"""List active (non-revoked) shareable links for this project's client."""
from backend.models import Project, ClientAccessToken, Client
project = db.query(Project).filter_by(id=project_id).first()
if not project or not project.client_id:
return {"client_name": None, "links": []}
client = db.query(Client).filter_by(id=project.client_id).first()
toks = (db.query(ClientAccessToken)
.filter_by(client_id=project.client_id, revoked_at=None)
.order_by(ClientAccessToken.created_at.desc()).all())
return {
"client_name": client.name if client else None,
"links": [{
"id": t.id, "label": t.label,
"created_at": t.created_at.isoformat() if t.created_at else None,
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
} for t in toks],
}
@app.post("/projects/{project_id}/portal-link/{token_id}/revoke")
async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)):
"""Revoke one shareable link (scoped to this project's client). Kills the link
and any live session minted from it on the next request."""
from datetime import datetime as _dt
from backend.models import Project, ClientAccessToken
project = db.query(Project).filter_by(id=project_id).first()
if not project or not project.client_id:
return JSONResponse(status_code=404, content={"detail": "Not found"})
tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first()
if not tok:
return JSONResponse(status_code=404, content={"detail": "Link not found"})
if not tok.revoked_at:
tok.revoked_at = _dt.utcnow()
db.commit()
return {"ok": True}
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
async def nrl_detail_page( async def nrl_detail_page(
request: Request, request: Request,
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Database migration: Client Portal (M1).
Adds the authoritative client link to projects:
- projects.client_id (TEXT, nullable) -> clients.id
The `clients` and `client_access_tokens` tables are created automatically by
SQLAlchemy `create_all` at app startup (they're brand-new tables), so this
migration only handles the column that create_all won't add to an existing
`projects` table.
Run once per database:
docker exec terra-view-terra-view-1 python3 backend/migrate_add_client_portal.py
"""
import sqlite3
from pathlib import Path
def migrate():
possible_paths = [
Path("data/seismo_fleet.db"),
Path("data/sfm.db"),
Path("data/seismo.db"),
]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("A fresh DB created via models.py will include projects.client_id automatically.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(projects)")
existing = {row[1] for row in cursor.fetchall()}
if "client_id" not in existing:
try:
cursor.execute("ALTER TABLE projects ADD COLUMN client_id TEXT")
print("✓ Added column: projects.client_id (TEXT)")
except sqlite3.OperationalError as e:
print(f"✗ Failed to add projects.client_id: {e}")
else:
print("○ Column already exists: projects.client_id")
conn.commit()
conn.close()
print("\n✓ Client-portal migration complete.")
print(" Note: `clients` + `client_access_tokens` tables auto-create on app startup.")
if __name__ == "__main__":
migrate()
+35
View File
@@ -192,6 +192,7 @@ class Project(Base):
# Project metadata # Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
site_address = Column(String, nullable=True) site_address = Column(String, nullable=True)
site_coordinates = Column(String, nullable=True) # "lat,lon" site_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True) start_date = Column(Date, nullable=True)
@@ -704,3 +705,37 @@ class PendingDeployment(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================================
# CLIENT PORTAL — read-only, scoped client access (see docs/CLIENT_PORTAL.md)
# ============================================================================
class Client(Base):
"""A portal client (customer org). Owns one or more Projects via
Project.client_id; their portal surfaces only those projects' locations.
Read-only clients never control devices."""
__tablename__ = "clients"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False) # display name, e.g. "PJ Dick"
slug = Column(String, nullable=False, unique=True, index=True) # URL-safe handle
contact_email = Column(String, nullable=True) # for M4 magic-link
active = Column(Boolean, default=True) # False = portal access off
created_at = Column(DateTime, default=datetime.utcnow)
class ClientAccessToken(Base):
"""Interim 'magic URL' gate (M1-M3). The raw secret lives in the link and is
shown once on creation; only its sha256 is stored here. Revoke by setting
revoked_at. In M4 this is replaced behind get_current_client() without
touching routes/templates."""
__tablename__ = "client_access_tokens"
id = Column(String, primary_key=True, index=True) # UUID
client_id = Column(String, nullable=False, index=True) # FK -> clients.id
token_hash = Column(String, nullable=False, index=True) # sha256 hex of the secret
label = Column(String, nullable=True) # e.g. "Dave's link"
created_at = Column(DateTime, default=datetime.utcnow)
last_used_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True) # set = link no longer works
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
Client-portal admin CLI (M1). Operator tooling run inside the terra-view
container against the live DB. The raw magic-link token is shown ONCE on mint;
only its hash is stored.
# create a client
python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com]
# attach a project to a client (sets Project.client_id) — by id, number, or name
python3 backend/portal_admin.py link-project --slug myler --project-id <PID>
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
# mint a magic access link (FULL URL PRINTED ONCE — copy it now)
python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"]
# list clients, their projects, and active links
python3 backend/portal_admin.py list
# revoke a link (stops the link AND any live session it minted)
python3 backend/portal_admin.py revoke --token-id <TID>
The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001).
"""
import os
import sys
import uuid
import secrets
import argparse
from datetime import datetime
# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0],
# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import SessionLocal
from backend.models import Client, ClientAccessToken, Project
from backend.portal_auth import hash_token
PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/")
def _get_client(db, slug):
c = db.query(Client).filter_by(slug=slug).first()
if not c:
sys.exit(f"No client with slug '{slug}'. Create it first.")
return c
def create_client(args):
db = SessionLocal()
try:
if db.query(Client).filter_by(slug=args.slug).first():
sys.exit(f"A client with slug '{args.slug}' already exists.")
c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug,
contact_email=args.email, active=True)
db.add(c)
db.commit()
print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})")
print(" Next: link-project, then mint-link.")
finally:
db.close()
def link_project(args):
db = SessionLocal()
try:
c = _get_client(db, args.slug)
q = db.query(Project)
if args.project_id:
p = q.filter_by(id=args.project_id).first()
elif args.project_number:
p = q.filter_by(project_number=args.project_number).first()
elif args.project_name:
p = q.filter_by(name=args.project_name).first()
else:
sys.exit("Specify --project-id, --project-number, or --project-name.")
if not p:
sys.exit("Project not found.")
p.client_id = c.id
db.commit()
print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'")
finally:
db.close()
def mint_link(args):
db = SessionLocal()
try:
c = _get_client(db, args.slug)
raw = secrets.token_urlsafe(32)
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id,
token_hash=hash_token(raw), label=args.label)
db.add(tok)
db.commit()
print(f"✓ Minted access link for '{c.name}'"
f"{f' ({args.label})' if args.label else ''} — token id {tok.id}")
print("\n COPY THIS NOW (shown only once):\n")
print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n")
finally:
db.close()
def revoke(args):
db = SessionLocal()
try:
tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first()
if not tok:
sys.exit("No token with that id.")
if tok.revoked_at:
print("○ Already revoked.")
return
tok.revoked_at = datetime.utcnow()
db.commit()
print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.")
finally:
db.close()
def list_all(args):
db = SessionLocal()
try:
clients = db.query(Client).order_by(Client.name).all()
if not clients:
print("No clients yet.")
return
for c in clients:
state = "" if c.active else " [INACTIVE]"
print(f"\n{c.name} (slug={c.slug}){state}")
projs = db.query(Project).filter_by(client_id=c.id).all()
print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)"))
toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all()
if not toks:
print(" links: (none — run mint-link)")
for t in toks:
status = "revoked" if t.revoked_at else "active"
last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used"
print(f" link {t.id} [{status}] {t.label or ''} (last: {last})")
print()
finally:
db.close()
def main():
ap = argparse.ArgumentParser(description="Client-portal admin (M1)")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("create-client"); p.add_argument("--name", required=True)
p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client)
p = sub.add_parser("link-project"); p.add_argument("--slug", required=True)
p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name")
p.set_defaults(fn=link_project)
p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True)
p.add_argument("--label"); p.set_defaults(fn=mint_link)
p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke)
p = sub.add_parser("list"); p.set_defaults(fn=list_all)
args = ap.parse_args()
args.fn(args)
if __name__ == "__main__":
main()
+183
View File
@@ -0,0 +1,183 @@
"""
Client-portal auth the swappable gate (see docs/CLIENT_PORTAL.md).
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
mints a signed session cookie. Every portal route depends on get_current_client();
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
against the DB on every request, so revoking a link (revoked_at) kills its live
sessions on the next request not just future clicks.
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
"""
import os
import hmac
import json
import time
import uuid
import base64
import hashlib
import logging
import secrets
from datetime import datetime
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Client, ClientAccessToken
logger = logging.getLogger(__name__)
# Signing secret for portal session cookies. MUST be set to a real secret in prod
# (env). The insecure default only exists so dev/test boots without config.
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
if SECRET_KEY == "dev-insecure-change-me":
logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")
COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
# Plain, no-token portal links (/portal/open/{project_id}). These are an
# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's
# open link grants the *whole* client's scope), so they default OFF and must be
# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env.
PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes")
if PORTAL_OPEN_LINKS:
logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
"Keep this OFF in any internet-facing / production deployment.")
class PortalAuthError(Exception):
"""Raised by get_current_client when there's no valid portal session.
Handled centrally in main.py: HTML routes get the access-required page,
/portal/api/* routes get a 401 JSON."""
# -- token + cookie primitives ----------------------------------------------
def hash_token(raw: str) -> str:
"""sha256 hex of a raw access-token secret (what we store + look up by)."""
return hashlib.sha256(raw.encode()).hexdigest()
def _sign(body: str) -> str:
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
def make_session_cookie(token_id: str) -> str:
body = base64.urlsafe_b64encode(
json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
).decode()
return f"{body}.{_sign(body)}"
def _read_session_cookie(value: str):
"""Return the token id from a signed cookie, or None if missing/tampered."""
try:
body, sig = value.rsplit(".", 1)
except (ValueError, AttributeError):
return None
if not hmac.compare_digest(sig, _sign(body)):
return None
try:
data = json.loads(base64.urlsafe_b64decode(body.encode()))
if not isinstance(data, dict):
return None
# Server-side expiry: a leaked cookie isn't valid forever (max_age is only a
# browser hint). iat is set by make_session_cookie.
iat = data.get("iat")
if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE:
return None
return data.get("tid")
except Exception:
return None
# -- the dependency every portal route uses ---------------------------------
def client_from_cookie(cookie_value, db: Session):
"""Resolve a Client from a raw session-cookie value, or None. Re-validates the
access token against the DB each call, so a revoked link / disabled client
drops immediately. Shared by the HTTP dependency and the WebSocket handler
(which can't use Request-based Depends)."""
token_id = _read_session_cookie(cookie_value) if cookie_value else None
if not token_id:
return None
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
if not tok:
return None
return db.query(Client).filter_by(id=tok.client_id, active=True).first()
def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
"""Resolve the authenticated client, or raise PortalAuthError."""
client = client_from_cookie(request.cookies.get(COOKIE_NAME), db)
if client is None:
raise PortalAuthError()
return client
def resolve_token(raw_token: str, db: Session):
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on
success, or (None, None). Also stamps last_used_at."""
tok = db.query(ClientAccessToken).filter_by(
token_hash=hash_token(raw_token), revoked_at=None
).first()
if not tok:
return None, None
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
if not client:
return None, None
tok.last_used_at = datetime.utcnow()
db.commit()
return tok, client
def ensure_project_client(project, db) -> Client:
"""Find or create the Client for a project. Reuses the project's linked client
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
sets project.client_id (only when unset, so it never clobbers a real link)."""
client = None
if project.client_id:
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
if client is None:
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
client = db.query(Client).filter_by(slug=slug).first()
if client is None:
client = Client(id=str(uuid.uuid4()),
name=(project.client_name or project.name or "Preview"),
slug=slug, active=True)
db.add(client)
db.flush()
if not project.client_id:
project.client_id = client.id
return client
def mint_link_token(client, db, label=None) -> str:
"""Mint a fresh access token for a client and return the RAW secret (caller
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored."""
raw = secrets.token_urlsafe(32)
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(raw), label=label))
db.commit()
return raw
def provision_preview_session(project, db) -> str:
"""Operator preview shortcut: ensure a Client + access token exist for a project
and return a token id to seal into a session cookie (no shared link). Reuses an
existing token so repeat previews don't accumulate clutter; the raw secret is
discarded (preview rides the cookie)."""
client = ensure_project_client(project, db)
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
if tok is None:
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(secrets.token_urlsafe(32)),
label="preview")
db.add(tok)
db.commit()
return tok.id
+3 -1
View File
@@ -9,6 +9,7 @@ import logging
import httpx import httpx
from backend.database import get_db from backend.database import get_db
from backend.models import UnitHistory, Emitter, RosterUnit from backend.models import UnitHistory, Emitter, RosterUnit
from backend.services.unit_location import get_active_location
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -140,6 +141,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
days = int(hours_ago / 24) days = int(hours_ago / 24)
time_ago = f"{days}d ago" time_ago = f"{days}d ago"
loc = get_active_location(db, emitter.id) if roster_unit else None
call_in = { call_in = {
"unit_id": emitter.id, "unit_id": emitter.id,
"last_seen": emitter.last_seen.isoformat(), "last_seen": emitter.last_seen.isoformat(),
@@ -148,7 +150,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
"device_type": roster_unit.device_type if roster_unit else "seismograph", "device_type": roster_unit.device_type if roster_unit else "seismograph",
"deployed": roster_unit.deployed if roster_unit else False, "deployed": roster_unit.deployed if roster_unit else False,
"note": roster_unit.note if roster_unit and roster_unit.note else "", "note": roster_unit.note if roster_unit and roster_unit.note else "",
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "") "location": (loc or {}).get("address") or (loc or {}).get("name") or ""
} }
call_ins.append(call_in) call_ins.append(call_in)
+31
View File
@@ -0,0 +1,31 @@
"""
Calibration Sync Router
Endpoints for triggering and inspecting the SFM-driven calibration sync.
The scheduled job runs daily; this router is what the "Sync now" button in
Settings calls, plus a status endpoint for diagnostics.
"""
from fastapi import APIRouter
from typing import Dict, Any
from backend.services.calibration_sync import (
sync_all_calibrations,
get_calibration_sync_scheduler,
)
router = APIRouter(prefix="/api/calibration", tags=["calibration"])
@router.post("/sync")
async def trigger_calibration_sync() -> Dict[str, Any]:
"""Run a full calibration sync now and return the summary."""
summary = await sync_all_calibrations()
get_calibration_sync_scheduler().last_run = summary
return summary
@router.get("/sync/status")
def calibration_sync_status() -> Dict[str, Any]:
"""Return scheduler status and the most recent run's summary."""
return get_calibration_sync_scheduler().status()
+5 -3
View File
@@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
# Last seen from emitter # Last seen from emitter
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first() emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
from backend.services.unit_location import get_active_location
loc = get_active_location(db, u.id)
return { return {
"id": u.id, "id": u.id,
"unit_type": u.unit_type, "unit_type": u.unit_type,
"deployed": u.deployed, "deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False, "out_for_calibration": u.out_for_calibration or False,
"note": u.note or "", "note": u.note or "",
"project_id": u.project_id or "", "project_id": (loc or {}).get("project_id") or u.project_id or "",
"address": u.address or u.location or "", "address": (loc or {}).get("address") or "",
"coordinates": u.coordinates or "", "coordinates": (loc or {}).get("coordinates") or "",
"deployed_with_modem_id": u.deployed_with_modem_id or "", "deployed_with_modem_id": u.deployed_with_modem_id or "",
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None), "next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
+12 -10
View File
@@ -14,6 +14,7 @@ import logging
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -85,8 +86,7 @@ async def get_modem_units(
(RosterUnit.id.ilike(search_term)) | (RosterUnit.id.ilike(search_term)) |
(RosterUnit.ip_address.ilike(search_term)) | (RosterUnit.ip_address.ilike(search_term)) |
(RosterUnit.hardware_model.ilike(search_term)) | (RosterUnit.hardware_model.ilike(search_term)) |
(RosterUnit.phone_number.ilike(search_term)) | (RosterUnit.phone_number.ilike(search_term))
(RosterUnit.location.ilike(search_term))
) )
modems = query.order_by( modems = query.order_by(
@@ -128,6 +128,8 @@ async def get_modem_units(
if filter_status and status != filter_status: if filter_status and status != filter_status:
continue continue
# Inherit location from the paired device's active assignment.
loc = get_active_location(db, modem.id) if paired else None
modem_list.append({ modem_list.append({
"id": modem.id, "id": modem.id,
"ip_address": modem.ip_address, "ip_address": modem.ip_address,
@@ -135,8 +137,8 @@ async def get_modem_units(
"hardware_model": modem.hardware_model, "hardware_model": modem.hardware_model,
"deployed": modem.deployed, "deployed": modem.deployed,
"retired": modem.retired, "retired": modem.retired,
"location": modem.location, "location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"project_id": modem.project_id, "project_id": (loc or {}).get("project_id") or modem.project_id,
"paired_device": paired, "paired_device": paired,
"status": status "status": status
}) })
@@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
).first() ).first()
if device: if device:
loc = get_active_location(db, device.id)
return { return {
"paired": True, "paired": True,
"device": { "device": {
"id": device.id, "id": device.id,
"device_type": device.device_type, "device_type": device.device_type,
"deployed": device.deployed, "deployed": device.deployed,
"project_id": device.project_id, "project_id": (loc or {}).get("project_id") or device.project_id,
"location": device.location or device.address "location": (loc or {}).get("address") or (loc or {}).get("name") or ""
} }
} }
@@ -314,8 +317,6 @@ async def get_pairable_devices(
query = query.filter( query = query.filter(
(RosterUnit.id.ilike(search_term)) | (RosterUnit.id.ilike(search_term)) |
(RosterUnit.project_id.ilike(search_term)) | (RosterUnit.project_id.ilike(search_term)) |
(RosterUnit.location.ilike(search_term)) |
(RosterUnit.address.ilike(search_term)) |
(RosterUnit.note.ilike(search_term)) (RosterUnit.note.ilike(search_term))
) )
@@ -338,12 +339,13 @@ async def get_pairable_devices(
if hide_paired and is_paired_to_other: if hide_paired and is_paired_to_other:
continue continue
loc = get_active_location(db, device.id)
device_list.append({ device_list.append({
"id": device.id, "id": device.id,
"device_type": device.device_type, "device_type": device.device_type,
"deployed": device.deployed, "deployed": device.deployed,
"project_id": device.project_id, "project_id": (loc or {}).get("project_id") or device.project_id,
"location": device.location or device.address, "location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"note": device.note, "note": device.note,
"paired_modem_id": device.deployed_with_modem_id, "paired_modem_id": device.deployed_with_modem_id,
"is_paired_to_this": is_paired_to_this, "is_paired_to_this": is_paired_to_this,
+358
View File
@@ -0,0 +1,358 @@
"""
Client portal read-only, scoped client view (see docs/CLIENT_PORTAL.md).
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
session cookie, then sees their locations (overview) and per-location read-only
live data sourced from SLMM's cache. Every data route re-checks ownership.
"""
import os
import json
import asyncio
import logging
from datetime import datetime
import httpx
import websockets
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket
from fastapi.responses import RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from backend.database import get_db, SessionLocal
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
from backend.templates_config import templates
from backend.portal_auth import (
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
provision_preview_session, PORTAL_OPEN_LINKS,
COOKIE_NAME, COOKIE_MAX_AGE,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/portal", tags=["portal"])
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
# Whitelist of fields the portal exposes to a client — sound metrics + run state
# only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed.
_PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time",
"lp", "leq", "lmax", "lpeak", "ln1", "ln2")
# -- scoping (every data route gates through these) --------------------------
def _client_project_ids(client: Client, db: Session) -> list:
return [r[0] for r in db.query(Project.id).filter(
Project.client_id == client.id, Project.status != "deleted").all()]
def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation:
"""Ownership gate: location must be a sound location in one of the client's
active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so
we never leak whether a location exists."""
loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first()
if (not loc or loc.location_type != "sound"
or loc.project_id not in _client_project_ids(client, db)):
raise HTTPException(status_code=404, detail="Location not found")
return loc
def active_unit_for_location(location_id: str, db: Session):
"""The SLM unit currently assigned to this location, or None."""
now = datetime.utcnow()
asg = (db.query(UnitAssignment)
.filter(UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.device_type == "slm",
or_(UnitAssignment.assigned_until.is_(None),
UnitAssignment.assigned_until > now))
.order_by(UnitAssignment.assigned_at.desc()).first())
return asg.unit_id if asg else None
def _client_locations(client: Client, db: Session) -> list:
"""The client's active sound locations (for the overview tiles + map)."""
pids = _client_project_ids(client, db)
if not pids:
return []
projs = {p.id: p.name for p in
db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()}
locs = (db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id.in_(pids),
MonitoringLocation.location_type == "sound",
MonitoringLocation.removed_at.is_(None))
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all())
return [{
"id": loc.id, "name": loc.name,
"address": loc.address, "coordinates": loc.coordinates,
"project_name": projs.get(loc.project_id),
"has_device": active_unit_for_location(loc.id, db) is not None,
} for loc in locs]
@router.get("/enter/{token}")
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
tok, client = resolve_token(token, db)
if not client:
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "invalid"},
status_code=403,
)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(
COOKIE_NAME, make_session_cookie(tok.id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
)
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
return resp
@router.get("/open/{project_id}")
def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Dev-only plain shareable link: open a project's client portal with no token
(gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback
sets the session cookie and lands on /portal. Lives under /portal so it works
through a reverse proxy that exposes only /portal/*."""
if not PORTAL_OPEN_LINKS:
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "required"},
status_code=404)
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "invalid"},
status_code=404)
token_id = provision_preview_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
return resp
@router.get("/logout")
def portal_logout():
resp = RedirectResponse(url="/portal/access", status_code=303)
resp.delete_cookie(COOKIE_NAME)
return resp
@router.get("/access")
def portal_access(request: Request):
"""Landing for an unauthenticated visitor (no valid link)."""
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "required"}
)
@router.get("")
def portal_home(request: Request, client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Client overview — their active sound locations with live tiles + a map."""
return templates.TemplateResponse(
"portal/overview.html",
{"request": request, "client": client,
"locations": _client_locations(client, db)},
)
@router.get("/location/{location_id}")
def portal_location(location_id: str, request: Request,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Read-only live view for one of the client's locations (404 if not owned)."""
loc = resolve_client_location(client, location_id, db)
return templates.TemplateResponse("portal/location.html", {
"request": request, "client": client, "location": loc,
"has_device": active_unit_for_location(location_id, db) is not None,
})
# -- scoped data (cache reads only — never hits the device) ------------------
@router.get("/api/location/{location_id}/live")
async def portal_location_live(location_id: str,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Scrubbed cached live reading for a location the client owns."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "data": None, "reason": "no_device"}
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
except Exception:
return {"status": "ok", "data": None, "reason": "unreachable"}
if r.status_code != 200:
return {"status": "ok", "data": None, "reason": "no_data"}
full = (r.json() or {}).get("data", {}) or {}
return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}}
@router.get("/api/location/{location_id}/history")
async def portal_location_history(location_id: str, hours: float = 2.0,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Cached chart trail for a location the client owns. (Trail rows are already
just timestamp + lp/leq/lmax/ln1/ln2 safe to pass through.)"""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "readings": []}
hours = max(0.1, min(hours, 48.0))
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history",
params={"hours": hours})
except Exception:
return {"status": "ok", "readings": []}
if r.status_code != 200:
return {"status": "ok", "readings": []}
raw = (r.json() or {}).get("readings", [])
fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints
return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]}
# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
"onset_value", "peak_value", "clear_at", "status")
@router.get("/api/location/{location_id}/events")
async def portal_location_events(location_id: str, limit: int = 20,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""Scrubbed breach history for a location the client owns (read-only)."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "events": []}
limit = max(1, min(limit, 100))
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
params={"limit": limit})
except Exception:
return {"status": "ok", "events": []}
if r.status_code != 200:
return {"status": "ok", "events": []}
raw = (r.json() or {}).get("events", [])
events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/
# hysteresis internals).
_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s",
"schedule_start", "schedule_end", "schedule_days")
@router.get("/api/location/{location_id}/thresholds")
async def portal_location_thresholds(location_id: str,
client: Client = Depends(get_current_client),
db: Session = Depends(get_db)):
"""The active alert limits for a location the client owns (enabled rules only),
so the client can see what they're being alerted on. Read-only, scrubbed."""
resolve_client_location(client, location_id, db)
unit_id = active_unit_for_location(location_id, db)
if not unit_id:
return {"status": "ok", "rules": []}
try:
async with httpx.AsyncClient(timeout=5.0) as hc:
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules")
except Exception:
return {"status": "ok", "rules": []}
if r.status_code != 200:
return {"status": "ok", "rules": []}
raw = (r.json() or {}).get("rules", [])
rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")]
return {"status": "ok", "rules": rules}
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
def _scrub_frame(raw: str):
"""Project a monitor frame down to the portal whitelist. Drops internal fields
(unit_id, raw_payload, lmin) before it reaches a client; passes control fields
(feed_status, heartbeat) + timestamp through. Returns None for a non-JSON frame
so the caller drops it rather than forwarding anything unscrubbed."""
try:
d = json.loads(raw)
except Exception:
return None
out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d}
if "timestamp" in d:
out["timestamp"] = d["timestamp"]
for ctrl in ("feed_status", "heartbeat"):
if ctrl in d:
out[ctrl] = d[ctrl]
return json.dumps(out)
@router.websocket("/api/location/{location_id}/stream")
async def portal_location_stream(websocket: WebSocket, location_id: str):
"""Live ~1Hz feed for a location the client owns. Auths via the session cookie,
enforces ownership, then bridges the unit's shared SLMM /monitor fan-out feed
to the browser (scrubbed). A viewer is just one more subscriber to the one
device feed no extra device connection."""
await websocket.accept()
# Auth + ownership on a short-lived session, then release it for the long bridge.
db = SessionLocal()
try:
client = client_from_cookie(websocket.cookies.get(COOKIE_NAME), db)
if client is None:
await websocket.close(code=1008) # policy violation (not authenticated)
return
try:
resolve_client_location(client, location_id, db)
except HTTPException:
await websocket.close(code=1008)
return
unit_id = active_unit_for_location(location_id, db)
finally:
db.close()
if not unit_id:
try:
await websocket.send_json({"feed_status": "no_device"})
finally:
await websocket.close(code=1000)
return
target = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
backend_ws = None
try:
backend_ws = await websockets.connect(target)
async def forward_to_client():
async for message in backend_ws:
frame = _scrub_frame(message)
if frame is not None:
await websocket.send_text(frame)
async def watch_client():
while True:
await websocket.receive_text()
tasks = [asyncio.ensure_future(forward_to_client()),
asyncio.ensure_future(watch_client())]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except Exception as e:
logger.warning(f"[PORTAL] stream {location_id}: {e}")
finally:
if backend_ws:
try:
await backend_ws.close()
except Exception:
pass
+3 -1
View File
@@ -1483,11 +1483,13 @@ async def get_available_units(
).distinct().all() ).distinct().all()
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids] assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
# These units have no active assignment by definition, so there's no
# current location to show — leave the field empty.
available_units = [ available_units = [
{ {
"id": unit.id, "id": unit.id,
"device_type": unit.device_type, "device_type": unit.device_type,
"location": unit.address or unit.location, "location": "",
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type, "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
"deployed": bool(unit.deployed), "deployed": bool(unit.deployed),
} }
+16 -61
View File
@@ -12,6 +12,7 @@ from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
import uuid import uuid
from backend.services.slmm_sync import sync_slm_to_slmm from backend.services.slmm_sync import sync_slm_to_slmm
from backend.services.unit_location import get_active_location
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -182,9 +183,6 @@ async def add_roster_unit(
out_for_calibration: str = Form(None), out_for_calibration: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -249,9 +247,6 @@ async def add_roster_unit(
out_for_calibration=out_for_calibration_bool, out_for_calibration=out_for_calibration_bool,
note=note, note=note,
project_id=project_id, project_id=project_id,
location=location,
address=address,
coordinates=coordinates,
last_updated=datetime.utcnow(), last_updated=datetime.utcnow(),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated=last_cal_date, last_calibrated=last_cal_date,
@@ -273,19 +268,15 @@ async def add_roster_unit(
slm_measurement_range=slm_measurement_range if slm_measurement_range else None, slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
) )
# Auto-fill data from modem if pairing and fields are empty # Auto-fill data from modem if pairing and fields are empty.
# Location/address/coordinates now come from MonitoringLocation via the
# active UnitAssignment, so there's nothing to copy from the modem row.
if deployed_with_modem_id: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem" RosterUnit.device_type == "modem"
).first() ).first()
if modem: if modem:
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id unit.project_id = modem.project_id
if not unit.note and modem.note: if not unit.note and modem.note:
@@ -493,6 +484,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
if not unit: if not unit:
raise HTTPException(status_code=404, detail="Unit not found") raise HTTPException(status_code=404, detail="Unit not found")
active_loc = get_active_location(db, unit_id)
return { return {
"id": unit.id, "id": unit.id,
"device_type": unit.device_type or "seismograph", "device_type": unit.device_type or "seismograph",
@@ -504,9 +497,11 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "", "allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
"note": unit.note or "", "note": unit.note or "",
"project_id": unit.project_id or "", "project_id": unit.project_id or "",
"location": unit.location or "", "active_location": active_loc,
"address": unit.address or "", # Convenience fields so the unit-detail page can read the same shape
"coordinates": unit.coordinates or "", # whether or not there's an active assignment.
"address": (active_loc or {}).get("address") or "",
"coordinates": (active_loc or {}).get("coordinates") or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "", "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "", "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
"deployed_with_modem_id": unit.deployed_with_modem_id or "", "deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -538,9 +533,6 @@ async def edit_roster_unit(
allocated_to_project_id: str = Form(None), allocated_to_project_id: str = Form(None),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -565,8 +557,6 @@ async def edit_roster_unit(
cascade_deployed: str = Form(None), cascade_deployed: str = Form(None),
cascade_retired: str = Form(None), cascade_retired: str = Form(None),
cascade_project: str = Form(None), cascade_project: str = Form(None),
cascade_location: str = Form(None),
cascade_coordinates: str = Form(None),
cascade_note: str = Form(None), cascade_note: str = Form(None),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
@@ -620,9 +610,6 @@ async def edit_roster_unit(
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
unit.note = note unit.note = note
unit.project_id = project_id unit.project_id = project_id
unit.location = location
unit.address = address
unit.coordinates = coordinates
unit.last_updated = datetime.utcnow() unit.last_updated = datetime.utcnow()
# Seismograph-specific fields # Seismograph-specific fields
@@ -630,20 +617,15 @@ async def edit_roster_unit(
unit.next_calibration_due = next_cal_date unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
# Auto-fill data from modem if pairing and fields are empty # Auto-fill data from modem if pairing and fields are empty.
# Location/address/coordinates live on MonitoringLocation now, nothing
# to copy across roster rows.
if deployed_with_modem_id: if deployed_with_modem_id:
modem = db.query(RosterUnit).filter( modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id, RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem" RosterUnit.device_type == "modem"
).first() ).first()
if modem: if modem:
# Only fill if the device field is empty
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id: if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id unit.project_id = modem.project_id
if not unit.note and modem.note: if not unit.note and modem.note:
@@ -769,26 +751,6 @@ async def edit_roster_unit(
record_history(db, paired_unit.id, "project_change", "project_id", record_history(db, paired_unit.id, "project_change", "project_id",
old_paired_project or "", project_id or "", f"cascade from {unit_id}") old_paired_project or "", project_id or "", f"cascade from {unit_id}")
# Cascade address/location
if cascade_location in ['true', 'True', '1', 'yes']:
old_paired_address = paired_unit.address
old_paired_location = paired_unit.location
paired_unit.address = address
paired_unit.location = location
paired_unit.last_updated = datetime.utcnow()
if old_paired_address != address:
record_history(db, paired_unit.id, "address_change", "address",
old_paired_address or "", address or "", f"cascade from {unit_id}")
# Cascade coordinates
if cascade_coordinates in ['true', 'True', '1', 'yes']:
old_paired_coords = paired_unit.coordinates
paired_unit.coordinates = coordinates
paired_unit.last_updated = datetime.utcnow()
if old_paired_coords != coordinates:
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
# Cascade note # Cascade note
if cascade_note in ['true', 'True', '1', 'yes']: if cascade_note in ['true', 'True', '1', 'yes']:
old_paired_note = paired_unit.note old_paired_note = paired_unit.note
@@ -1011,9 +973,8 @@ async def import_csv(
- retired: Boolean - retired: Boolean
- note: Notes about the unit - note: Notes about the unit
- project_id: Project identifier - project_id: Project identifier
- location: Location description (Location / address / coordinates are not roster fields anymore they
- address: Street address live on the MonitoringLocation a unit is assigned to.)
- coordinates: GPS coordinates (lat;lon or lat,lon)
Seismograph-specific: Seismograph-specific:
- last_calibrated: Date (YYYY-MM-DD) - last_calibrated: Date (YYYY-MM-DD)
@@ -1126,9 +1087,6 @@ async def import_csv(
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note) existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id) existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow() existing_unit.last_updated = datetime.utcnow()
# Seismograph-specific fields # Seismograph-specific fields
@@ -1194,9 +1152,6 @@ async def import_csv(
retired=_parse_bool(row.get('retired', '')), retired=_parse_bool(row.get('retired', '')),
note=_get_csv_value(row, 'note', ''), note=_get_csv_value(row, 'note', ''),
project_id=_get_csv_value(row, 'project_id'), project_id=_get_csv_value(row, 'project_id'),
location=_get_csv_value(row, 'location'),
address=_get_csv_value(row, 'address'),
coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(), last_updated=datetime.utcnow(),
# Seismograph fields - auto-calc next_calibration_due from last_calibrated # Seismograph fields - auto-calc next_calibration_due from last_calibrated
last_calibrated=last_cal, last_calibrated=last_cal,
+11 -9
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
from backend.services.database_backup import DatabaseBackupService from backend.services.database_backup import DatabaseBackupService
from backend.services.unit_location import bulk_active_locations
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -21,11 +22,14 @@ def export_roster_csv(db: Session = Depends(get_db)):
"""Export all roster units to CSV""" """Export all roster units to CSV"""
units = db.query(RosterUnit).all() units = db.query(RosterUnit).all()
# Create CSV in memory # Create CSV in memory. Location lives on MonitoringLocation now, so
# we don't export legacy address/coordinates/location columns here —
# round-trip CSV editing would otherwise look like it edits unit
# location, when it can't.
output = io.StringIO() output = io.StringIO()
fieldnames = [ fieldnames = [
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired', 'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
'note', 'project_id', 'location', 'address', 'coordinates', 'note', 'project_id',
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id', 'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
'ip_address', 'phone_number', 'hardware_model' 'ip_address', 'phone_number', 'hardware_model'
] ]
@@ -42,9 +46,6 @@ def export_roster_csv(db: Session = Depends(get_db)):
'retired': 'true' if unit.retired else 'false', 'retired': 'true' if unit.retired else 'false',
'note': unit.note or '', 'note': unit.note or '',
'project_id': unit.project_id or '', 'project_id': unit.project_id or '',
'location': unit.location or '',
'address': unit.address or '',
'coordinates': unit.coordinates or '',
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '', 'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '', 'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
'deployed_with_modem_id': unit.deployed_with_modem_id or '', 'deployed_with_modem_id': unit.deployed_with_modem_id or '',
@@ -82,6 +83,7 @@ def get_table_stats(db: Session = Depends(get_db)):
def get_all_roster_units(db: Session = Depends(get_db)): def get_all_roster_units(db: Session = Depends(get_db)):
"""Get all roster units for management table""" """Get all roster units for management table"""
units = db.query(RosterUnit).order_by(RosterUnit.id).all() units = db.query(RosterUnit).order_by(RosterUnit.id).all()
active_locs = bulk_active_locations(db, units)
return [{ return [{
"id": unit.id, "id": unit.id,
@@ -90,10 +92,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
"deployed": unit.deployed, "deployed": unit.deployed,
"retired": unit.retired, "retired": unit.retired,
"note": unit.note or "", "note": unit.note or "",
"project_id": unit.project_id or "", "project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
"location": unit.location or "", "address": (active_locs.get(unit.id) or {}).get("address") or "",
"address": unit.address or "", "coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
"coordinates": unit.coordinates or "", "location_name": (active_locs.get(unit.id) or {}).get("name") or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None, "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None, "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
"deployed_with_modem_id": unit.deployed_with_modem_id or "", "deployed_with_modem_id": unit.deployed_with_modem_id or "",
+42 -35
View File
@@ -91,29 +91,43 @@ async def get_slm_units(
one_hour_ago = datetime.utcnow() - timedelta(hours=1) one_hour_ago = datetime.utcnow() - timedelta(hours=1)
for unit in units: for unit in units:
# Legacy default from the roster field; refined from SLMM's cached status below.
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago) unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
unit.measurement_state = None
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
if include_measurement: if include_measurement:
async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None: # SLMM's /roster carries each unit's CACHED status (last_seen,
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects
# real monitoring without sending Measure? to the device (which the old
# /measurement-state did) and competing with DOD polling. One call covers all.
slmm_status = {}
try: try:
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
if response.status_code == 200:
return response.json().get("measurement_state")
except Exception:
return None
return None
deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
if deployed_units:
async with httpx.AsyncClient(timeout=3.0) as client: async with httpx.AsyncClient(timeout=3.0) as client:
tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units] r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster")
results = await asyncio.gather(*tasks, return_exceptions=True) if r.status_code == 200:
for dev in (r.json().get("devices") or []):
slmm_status[dev.get("unit_id")] = dev.get("status") or {}
except Exception:
slmm_status = {}
for unit, state in zip(deployed_units, results): # "Recent" = the monitor has a fresh successful read. last_seen only advances
if isinstance(state, Exception): # on a successful poll, so staleness == the device isn't being reached.
unit.measurement_state = None recent_cutoff = datetime.utcnow() - timedelta(minutes=5)
else: for unit in units:
unit.measurement_state = state st = slmm_status.get(unit.id)
if not st:
continue
unit.measurement_state = st.get("measurement_state")
last_seen = st.get("last_seen")
if last_seen:
try:
ls = datetime.fromisoformat(last_seen.replace("Z", ""))
unit.is_recent = ls > recent_cutoff
unit.cache_last_seen = ls # the real freshness the monitor updates
except Exception:
pass
return templates.TemplateResponse("partials/slm_device_list.html", { return templates.TemplateResponse("partials/slm_device_list.html", {
"request": request, "request": request,
@@ -157,25 +171,18 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
is_measuring = False is_measuring = False
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: # Read SLMM's CACHED status (NL43Status) — no device call. The live monitor
# Get measurement state # keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we
state_response = await client.get( # no longer fire Measure? + a fresh DOD read at the device on every command-
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state" # center load (which competed with DOD polling for the single connection).
) async with httpx.AsyncClient(timeout=5.0) as client:
if state_response.status_code == 200: r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
state_data = state_response.json() if r.status_code == 200:
measurement_state = state_data.get("measurement_state", "Unknown") current_status = r.json().get("data", {})
is_measuring = state_data.get("is_measuring", False) measurement_state = current_status.get("measurement_state")
is_measuring = measurement_state in ("Start", "Measure")
# Get live status (measurement_start_time is already stored in SLMM database)
status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
)
if status_response.status_code == 200:
status_data = status_response.json()
current_status = status_data.get("data", {})
except Exception as e: except Exception as e:
logger.error(f"Failed to get status for {unit_id}: {e}") logger.error(f"Failed to get cached status for {unit_id}: {e}")
return templates.TemplateResponse("partials/slm_live_view.html", { return templates.TemplateResponse("partials/slm_live_view.html", {
"request": request, "request": request,
+4 -2
View File
@@ -14,6 +14,7 @@ import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,13 +59,14 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
except Exception as e: except Exception as e:
logger.warning(f"Failed to get SLM status for {unit_id}: {e}") logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
loc = get_active_location(db, unit_id)
return { return {
"unit_id": unit_id, "unit_id": unit_id,
"device_type": "slm", "device_type": "slm",
"deployed": unit.deployed, "deployed": unit.deployed,
"model": unit.slm_model or "NL-43", "model": unit.slm_model or "NL-43",
"location": unit.address or unit.location, "location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"coordinates": unit.coordinates, "coordinates": (loc or {}).get("coordinates") or "",
"note": unit.note, "note": unit.note,
"status": status_data, "status": status_data,
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None, "last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
+70
View File
@@ -231,6 +231,76 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
logger.info(f"WebSocket proxy closed for {unit_id} (live)") logger.info(f"WebSocket proxy closed for {unit_id} (live)")
@router.websocket("/{unit_id}/monitor")
async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str):
"""
Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed).
This is the shared ~1Hz DOD feed: many clients subscribe to one device feed
(no single-connection contention) and it carries L1/L10 (which the DRD
/stream cannot). Preferred over /stream for the live view.
"""
await websocket.accept()
logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)")
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
backend_ws = None
try:
backend_ws = await websockets.connect(target_ws_url)
logger.info(f"Connected to SLMM monitor feed for {unit_id}")
async def forward_to_client():
"""Backend monitor frames -> browser."""
async for message in backend_ws:
await websocket.send_text(message)
async def watch_client():
"""Drain client frames; raises WebSocketDisconnect on close so we can
tear the pair down (the monitor feed is server->client only)."""
while True:
await websocket.receive_text()
# When EITHER side ends (browser disconnects or backend closes), cancel the
# other immediately — avoids sending into a closed socket (the
# "Unexpected ASGI message after close" race that asyncio.gather leaves open).
tasks = [asyncio.ensure_future(forward_to_client()),
asyncio.ensure_future(watch_client())]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
# Await ALL tasks (the done one AND the cancelled one) and swallow both
# the expected WebSocketDisconnect and CancelledError. CancelledError is a
# BaseException, so a bare `except Exception` misses it — that's what leaked
# the traceback on stop; and awaiting only `pending` left the done task's
# exception unretrieved.
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except websockets.exceptions.WebSocketException as e:
logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}")
try:
await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)})
except Exception:
pass
except Exception as e:
logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}")
finally:
if backend_ws:
try:
await backend_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"WebSocket monitor proxy closed for {unit_id}")
# HTTP catch-all route MUST come after specific routes (including WebSocket routes) # HTTP catch-all route MUST come after specific routes (including WebSocket routes)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_slmm(path: str, request: Request): async def proxy_to_slmm(path: str, request: Request):
+12 -16
View File
@@ -5,6 +5,7 @@ from typing import Dict, Any, Optional
from backend.database import get_db from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.services.unit_location import get_active_location
from backend.models import RosterUnit from backend.models import RosterUnit
router = APIRouter(prefix="/api", tags=["units"]) router = APIRouter(prefix="/api", tags=["units"])
@@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"])
@router.get("/unit/{unit_id}") @router.get("/unit/{unit_id}")
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
""" """
Returns detailed data for a single unit. Returns detailed data for a single unit, including its active deployment
location (or None if benched / unassigned).
""" """
snapshot = emit_status_snapshot() snapshot = emit_status_snapshot()
@@ -21,17 +23,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
unit_data = snapshot["units"][unit_id] unit_data = snapshot["units"][unit_id]
active_loc = get_active_location(db, unit_id)
# Mock coordinates for now (will be replaced with real data)
mock_coords = {
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
}
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
return { return {
"id": unit_id, "id": unit_id,
@@ -41,7 +33,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
"last_file": unit_data.get("fname", ""), "last_file": unit_data.get("fname", ""),
"deployed": unit_data["deployed"], "deployed": unit_data["deployed"],
"note": unit_data.get("note", ""), "note": unit_data.get("note", ""),
"coordinates": coords "active_location": active_loc,
} }
@@ -49,12 +41,16 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)): def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
""" """
Get unit data directly from the roster (for settings/configuration). Get unit data directly from the roster (for settings/configuration).
Address/coordinates come from the active MonitoringLocation, not the
roster row.
""" """
unit = db.query(RosterUnit).filter_by(id=unit_id).first() unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit: if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
active_loc = get_active_location(db, unit_id)
return { return {
"id": unit.id, "id": unit.id,
"unit_type": unit.unit_type, "unit_type": unit.unit_type,
@@ -62,9 +58,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"deployed": unit.deployed, "deployed": unit.deployed,
"retired": unit.retired, "retired": unit.retired,
"note": unit.note, "note": unit.note,
"location": unit.location, "active_location": active_loc,
"address": unit.address, "address": (active_loc or {}).get("address") or "",
"coordinates": unit.coordinates, "coordinates": (active_loc or {}).get("coordinates") or "",
"slm_host": unit.slm_host, "slm_host": unit.slm_host,
"slm_tcp_port": unit.slm_tcp_port, "slm_tcp_port": unit.slm_tcp_port,
"slm_ftp_port": unit.slm_ftp_port, "slm_ftp_port": unit.slm_ftp_port,
+295
View File
@@ -0,0 +1,295 @@
"""
Calibration Sync Service
Pulls device-reported calibration dates from SFM event sidecars and updates
RosterUnit.last_calibrated when the device has a newer record than what
Terra-View has stored.
Conflict rule: events-as-truth, but don't go backwards.
- If the newest event's calibration_date == unit.last_calibrated → no-op.
- If the last UnitHistory change for last_calibrated is newer than the
newest event's timestamp → skip (a manual edit was made after this
event landed; manual wins until a fresher event arrives).
- Otherwise write the event's calibration_date, recompute
next_calibration_due, and log a UnitHistory row with source='sfm_event'.
"""
import asyncio
import logging
import os
import threading
import time
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Any, List
import httpx
import schedule
from sqlalchemy.orm import Session
from backend.database import SessionLocal
from backend.models import RosterUnit, UnitHistory, UserPreferences
logger = logging.getLogger(__name__)
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
def _get_cal_interval(db: Session) -> int:
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
def _parse_event_ts(value: Any) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None) if value.tzinfo else value
try:
s = str(value).replace("Z", "")
if "+" in s:
s = s.split("+", 1)[0]
return datetime.fromisoformat(s)
except (ValueError, TypeError):
logger.warning(f"Could not parse event timestamp: {value!r}")
return None
def _parse_cal_date(value: Any) -> Optional[date]:
if not value:
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
try:
return datetime.fromisoformat(str(value)).date()
except (ValueError, TypeError):
try:
return datetime.strptime(str(value), "%Y-%m-%d").date()
except (ValueError, TypeError):
logger.warning(f"Could not parse calibration_date: {value!r}")
return None
async def _get_latest_event(client: httpx.AsyncClient, serial: str) -> Optional[Dict[str, Any]]:
try:
resp = await client.get(
f"{SFM_BASE_URL}/db/events",
params={"serial": serial, "limit": 1},
)
resp.raise_for_status()
data = resp.json()
events = data.get("events", [])
return events[0] if events else None
except (httpx.HTTPError, ValueError) as e:
logger.warning(f"Failed to fetch latest event for {serial}: {e}")
return None
async def _get_event_sidecar(client: httpx.AsyncClient, event_id: str) -> Optional[Dict[str, Any]]:
try:
resp = await client.get(f"{SFM_BASE_URL}/db/events/{event_id}/sidecar")
resp.raise_for_status()
return resp.json()
except (httpx.HTTPError, ValueError) as e:
logger.warning(f"Failed to fetch sidecar for event {event_id}: {e}")
return None
async def sync_unit_calibration(
db: Session,
unit: RosterUnit,
client: httpx.AsyncClient,
) -> Dict[str, Any]:
"""Sync calibration for one seismograph unit. Returns a result dict."""
result: Dict[str, Any] = {
"unit_id": unit.id,
"action": "checked",
"old": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"new": None,
"event_id": None,
}
event = await _get_latest_event(client, unit.id)
if not event:
result["action"] = "no_event"
return result
sidecar = await _get_event_sidecar(client, event["id"])
if not sidecar:
result["action"] = "no_sidecar"
return result
device = sidecar.get("device") or {}
event_cal = _parse_cal_date(device.get("calibration_date"))
if not event_cal:
result["action"] = "no_cal_in_sidecar"
return result
result["event_id"] = event["id"]
result["new"] = event_cal.isoformat()
if unit.last_calibrated == event_cal:
result["action"] = "already_in_sync"
return result
event_ts = _parse_event_ts(event.get("timestamp"))
last_change = (
db.query(UnitHistory)
.filter(
UnitHistory.unit_id == unit.id,
UnitHistory.field_name == "last_calibrated",
)
.order_by(UnitHistory.changed_at.desc())
.first()
)
if last_change and event_ts and last_change.changed_at > event_ts:
result["action"] = "skipped_manual_newer"
return result
old_cal = unit.last_calibrated
unit.last_calibrated = event_cal
unit.next_calibration_due = event_cal + timedelta(days=_get_cal_interval(db))
db.add(UnitHistory(
unit_id=unit.id,
change_type="calibration_status_change",
field_name="last_calibrated",
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
new_value=event_cal.strftime("%Y-%m-%d"),
source="sfm_event",
notes=f"Synced from event {event['id']}",
))
result["action"] = "updated"
return result
async def sync_all_calibrations(db: Optional[Session] = None) -> Dict[str, Any]:
"""Sync calibration for every non-retired seismograph.
If `db` is provided the caller owns the session and commit. Otherwise
a session is opened, committed, and closed locally this is what the
scheduled job uses.
"""
owns_session = db is None
if owns_session:
db = SessionLocal()
summary: Dict[str, Any] = {
"started_at": datetime.utcnow().isoformat(),
"checked": 0,
"updated": 0,
"skipped_manual_newer": 0,
"already_in_sync": 0,
"no_event": 0,
"no_sidecar": 0,
"no_cal_in_sidecar": 0,
"errors": 0,
"results": [],
}
try:
units = (
db.query(RosterUnit)
.filter(
RosterUnit.retired == False,
RosterUnit.device_type == "seismograph",
)
.all()
)
async with httpx.AsyncClient(timeout=15.0) as client:
for unit in units:
summary["checked"] += 1
try:
r = await sync_unit_calibration(db, unit, client)
except Exception as e:
logger.exception(f"Error syncing calibration for {unit.id}")
summary["errors"] += 1
summary["results"].append({"unit_id": unit.id, "action": "error", "error": str(e)})
continue
summary["results"].append(r)
action = r["action"]
if action in summary:
summary[action] += 1
if owns_session:
db.commit()
finally:
if owns_session:
db.close()
summary["finished_at"] = datetime.utcnow().isoformat()
logger.info(
f"Calibration sync done: checked={summary['checked']} "
f"updated={summary['updated']} skipped_manual={summary['skipped_manual_newer']} "
f"in_sync={summary['already_in_sync']} errors={summary['errors']}"
)
return summary
# ---------------------------------------------------------------------------
# Background scheduler — runs once daily. Modeled on backup_scheduler.
# ---------------------------------------------------------------------------
class CalibrationSyncScheduler:
"""Runs sync_all_calibrations() once per day at a fixed local time."""
def __init__(self, run_at: str = "03:15"):
self.run_at = run_at
self.is_running = False
self.thread: Optional[threading.Thread] = None
self.last_run: Optional[Dict[str, Any]] = None
def _job_wrapper(self):
"""Run the async sync in a fresh event loop (we're on a worker thread)."""
try:
self.last_run = asyncio.run(sync_all_calibrations())
except Exception as e:
logger.exception(f"Calibration sync job failed: {e}")
self.last_run = {"error": str(e), "finished_at": datetime.utcnow().isoformat()}
def start(self):
if self.is_running:
return
logger.info(f"Starting calibration sync scheduler (daily at {self.run_at})")
schedule.every().day.at(self.run_at).do(self._job_wrapper)
self.is_running = True
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _loop(self):
while self.is_running:
schedule.run_pending()
time.sleep(60)
def stop(self):
if not self.is_running:
return
logger.info("Stopping calibration sync scheduler")
self.is_running = False
if self.thread:
self.thread.join(timeout=5)
def status(self) -> Dict[str, Any]:
return {
"running": self.is_running,
"run_at": self.run_at,
"last_run": self.last_run,
}
_scheduler: Optional[CalibrationSyncScheduler] = None
def get_calibration_sync_scheduler() -> CalibrationSyncScheduler:
global _scheduler
if _scheduler is None:
_scheduler = CalibrationSyncScheduler()
return _scheduler
+21 -6
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db_session from backend.database import get_db_session
from backend.models import Emitter, RosterUnit, IgnoredUnit from backend.models import Emitter, RosterUnit, IgnoredUnit
from backend.services.unit_location import bulk_active_locations
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -137,6 +138,10 @@ def emit_status_snapshot():
emitters = {e.id: e for e in db.query(Emitter).all()} emitters = {e.id: e for e in db.query(Emitter).all()}
ignored = {i.id for i in db.query(IgnoredUnit).all()} ignored = {i.id for i in db.query(IgnoredUnit).all()}
# Active-assignment location lookup for all roster units (direct only;
# modems inherit from their paired device below in the derive loop).
active_locs = bulk_active_locations(db, list(roster.values()))
# SFM event-forwards are now the primary "last seen" signal for # SFM event-forwards are now the primary "last seen" signal for
# seismographs. Watcher heartbeats stay as a backup — if SFM is down # seismographs. Watcher heartbeats stay as a backup — if SFM is down
# or hasn't seen a serial, we fall back to Emitter.last_seen. # or hasn't seen a serial, we fall back to Emitter.last_seen.
@@ -225,10 +230,13 @@ def emit_status_snapshot():
"ip_address": r.ip_address, "ip_address": r.ip_address,
"phone_number": r.phone_number, "phone_number": r.phone_number,
"hardware_model": r.hardware_model, "hardware_model": r.hardware_model,
# Location for mapping # Location for mapping — sourced from active UnitAssignment
"location": r.location or "", # → MonitoringLocation. Empty for benched / unassigned.
"address": r.address or "", "address": (active_locs.get(unit_id) or {}).get("address") or "",
"coordinates": r.coordinates or "", "coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "",
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
} }
# --- Add unexpected emitter-only units --- # --- Add unexpected emitter-only units ---
@@ -267,10 +275,12 @@ def emit_status_snapshot():
"ip_address": None, "ip_address": None,
"phone_number": None, "phone_number": None,
"hardware_model": None, "hardware_model": None,
# Location fields # Location fields — unknown units have no assignment
"location": "",
"address": "", "address": "",
"coordinates": "", "coordinates": "",
"location_name": "",
"project_id": "",
"location_id": "",
} }
# --- Derive modem status from paired devices --- # --- Derive modem status from paired devices ---
@@ -301,6 +311,11 @@ def emit_status_snapshot():
unit_data["last"] = paired_unit.get("last") unit_data["last"] = paired_unit.get("last")
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none") unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
unit_data["derived_from"] = paired_unit_id unit_data["derived_from"] = paired_unit_id
# Inherit deployment location too — modems don't carry
# their own UnitAssignment.
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
if not unit_data.get(k):
unit_data[k] = paired_unit.get(k, "")
# Separate buckets for UI # Separate buckets for UI
active_units = { active_units = {
+125
View File
@@ -0,0 +1,125 @@
"""
Active-assignment location resolution for roster units.
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
The current source of truth for "where is this unit deployed right now" is the
active `UnitAssignment` (assigned_until IS NULL) pointing at a
`MonitoringLocation`, which carries the canonical address/coordinates/name.
Modems don't get their own `UnitAssignment` — they're paired with a
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
location of its paired device's active assignment.
Returned dict shape (or None if no active assignment resolvable):
{
"location_id": "uuid",
"project_id": "uuid",
"name": "NRL-001",
"address": "123 Main St" | None,
"coordinates": "34.0522,-118.2437" | None,
"via_paired_unit_id": "BE1234" | None, # set only for modems
}
"""
from typing import Optional
from sqlalchemy.orm import Session
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
return {
"location_id": loc.id,
"project_id": loc.project_id,
"name": loc.name,
"address": loc.address or None,
"coordinates": loc.coordinates or None,
"via_paired_unit_id": via_paired_unit_id,
}
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
row = (
db.query(MonitoringLocation)
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
.filter(
UnitAssignment.unit_id == unit_id,
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.first()
)
return row
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
"""
Resolve the active deployment location for a unit.
Seismographs / SLMs: their own active UnitAssignment.
Modems: follow `deployed_with_unit_id` to the paired device's active
assignment (modems don't carry their own assignment).
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if unit is None:
return None
if (unit.device_type or "seismograph") == "modem":
paired_id = unit.deployed_with_unit_id
if not paired_id:
return None
loc = _active_location_for_unit_id(db, paired_id)
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
loc = _active_location_for_unit_id(db, unit_id)
return _serialize(loc) if loc else None
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
"""
Resolve active locations for many units in two queries. Use this from
snapshot-style loops to avoid N+1 lookups.
Returns {unit_id: <serialized location dict>} only populated for units
that resolve to an active assignment. Modems are resolved by walking
`deployed_with_unit_id` to the paired device's entry in the same map.
"""
if not units:
return {}
direct_unit_ids = [
u.id for u in units
if (u.device_type or "seismograph") != "modem"
]
direct: dict[str, MonitoringLocation] = {}
if direct_unit_ids:
rows = (
db.query(UnitAssignment.unit_id, MonitoringLocation)
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
.filter(
UnitAssignment.unit_id.in_(direct_unit_ids),
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.all()
)
# First row wins per unit_id (most recent assigned_at).
for unit_id, loc in rows:
direct.setdefault(unit_id, loc)
out: dict[str, dict] = {
uid: _serialize(loc) for uid, loc in direct.items()
}
# Modems inherit from paired device.
for u in units:
if (u.device_type or "seismograph") != "modem":
continue
paired_id = u.deployed_with_unit_id
if paired_id and paired_id in direct:
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
return out
+11 -2
View File
@@ -1,18 +1,27 @@
/* Service Worker for Seismo Fleet Manager PWA */ /* Service Worker for Seismo Fleet Manager PWA */
/* Network-first strategy with cache fallback for real-time data */ /* Network-first strategy with cache fallback for real-time data */
const CACHE_VERSION = 'v1'; // IMPORTANT: bump this on every release that touches a precached or
// runtime-cached static asset (event-modal.js, mobile.js, style.css,
// templates served at /, etc.). The activate handler deletes any cache
// not matching CACHE_VERSION, so old SW caches get evicted and mobile
// 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
// version string in backend/main.py.
const CACHE_VERSION = 'v0.13.2';
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}`;
// Files to precache (critical app shell) // Files to precache (critical app shell). event-modal.js is included
// so its cache lifecycle is tied to the SW version bump explicitly.
const STATIC_FILES = [ const STATIC_FILES = [
'/', '/',
'/static/style.css', '/static/style.css',
'/static/mobile.css', '/static/mobile.css',
'/static/mobile.js', '/static/mobile.js',
'/static/offline-db.js', '/static/offline-db.js',
'/static/event-modal.js',
'/static/manifest.json', '/static/manifest.json',
'https://cdn.tailwindcss.com', 'https://cdn.tailwindcss.com',
'https://unpkg.com/htmx.org@1.9.10', 'https://unpkg.com/htmx.org@1.9.10',
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
terra-view: web-app:
build: . build: .
ports: ports:
- "8001:8001" - "8001:8001"
+208
View File
@@ -0,0 +1,208 @@
# Client Portal — Design & Build Plan
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
A client-facing, **read-only**, **scoped** view into a client's own monitoring
data. The first internet-facing-with-real-clients surface in the system. Built
*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM
reads and Terra-View's report generation — Terra-View stays the UI/business layer;
SLMM stays the device layer.
## Principles
1. **Read-only.** No device control (start/stop/config), no roster editing, no
internal pages. A client can look, never touch.
2. **Strictly scoped.** A client only ever sees data for *their* projects. Every
portal endpoint verifies ownership server-side — never trust a `unit_id` /
`location_id` from the request.
3. **Cache-first, no device contention.** Portal live data comes from SLMM's
cache (the same cached `/status` + `/history` the internal dashboard uses).
No device-hitting calls from the portal — a client can't make us hammer the
NL-43. Freshness depends on **keepalive being on** for the client's units.
4. **Auth is a swappable gate.** Every route depends on one resolver,
`get_current_client()`. M1M3 ride on an interim signed "magic URL"; M4
replaces the resolver's backing without touching routes or templates.
## The data chain (how a client maps to live data)
```
Client.id
└─ Project (client_id == Client.id, status != deleted)
└─ MonitoringLocation (project_id, location_type == "sound", removed_at IS NULL)
└─ UnitAssignment (location_id, status == "active", device_type == "slm",
assigned_until IS NULL or future)
└─ unit_id == RosterUnit.id == SLMM unit_id
└─ SLMM cached /status + /history (read-only)
```
So the portal shows a client their **locations**, each surfacing the live sound
level from whatever SLM is currently assigned there.
## Data model (new)
```python
class Client(Base): # the customer org
id, name, slug (unique, URL-safe), contact_email (nullable, for M4),
active (bool), created_at
class ClientAccessToken(Base): # the interim "magic URL" gate
id, client_id, token_hash (sha256 — raw shown once on creation),
label, created_at, last_used_at, revoked_at (nullable)
```
Plus a migration adding **`Project.client_id`** (nullable FK → `clients.id`).
The existing free-text `Project.client_name` stays for display/back-compat;
`client_id` is the authoritative link.
## Auth — the swappable gate
```python
def get_current_client(request, db) -> Client: # every /portal route depends on this
# M1M3: read signed `portal_client` cookie -> load Client
# M4: same signature, backed by real sessions (magic-link / password)
```
**Interim "magic URL" flow (M1M3):**
- Operator creates a `Client` + an access token → gets a one-time-display URL:
`https://…/portal/enter/{token}`.
- Client clicks it → token is hashed, looked up (must be un-revoked) →
sets a **signed session cookie** (`portal_client`, HMAC via a new `SECRET_KEY`
env) → redirects to `/portal`. `last_used_at` updated.
- `get_current_client` reads + verifies the cookie thereafter. No valid cookie →
"link invalid / expired" page.
- Revoke = set `revoked_at`; the link (and any cookie minted from it) stops working.
Unguessable + revocable + per-person, no email infra or passwords yet — and M4
slots in behind the same `get_current_client` with zero route/template churn.
## Routes (`/portal/*`)
| Route | Purpose |
|-------|---------|
| `GET /portal/enter/{token}` | validate token → set cookie → redirect to `/portal` |
| `GET /portal` | client's locations overview (status tiles + map) |
| `GET /portal/location/{id}` | read-only live panel for that location's SLM |
| `GET /portal/api/location/{id}/live` | **scoped** cached `/status` for the location's unit |
| `GET /portal/api/location/{id}/history` | **scoped** cached trail for the chart |
| `GET /portal/logout` | clear cookie |
**Scoping helper** (used by every data route):
`resolve_client_location(client, location_id, db) -> (location, unit_id)` — raises
403 if the location isn't in one of the client's projects. The portal never calls
the open `/api/slmm/{unit}/*` endpoints with a client-supplied id.
## Templates (`templates/portal/`)
- `portal/base.html` — minimal client-branded shell (no internal sidebar/nav).
- `portal/overview.html` — location tiles (live cards mini) + a locations map.
- `portal/location.html` — the read-only live panel: cards (Lp/Leq/Lmax/L1/L10),
L1/L10 chart, measuring + freshness badge. Reuses the cache-populate JS from the
internal panel, **stripped** of start/stop, config, and the device-hitting
refresh (cache + 15s auto-poll only).
---
## Milestones
### M1 — Live view only *(current)*
Interim magic-URL gate; a client sees their locations and per-location read-only
live data, all from cache.
- [ ] `Client` + `ClientAccessToken` models; `Project.client_id` migration.
- [ ] `SECRET_KEY` env + signed-cookie session helper.
- [ ] `get_current_client` dependency + `/portal/enter/{token}` + logout.
- [ ] Scoping helper `resolve_client_location`.
- [ ] `/portal` overview + `/portal/location/{id}` (read-only live panel).
- [ ] Scoped `/portal/api/location/{id}/live` + `/history`.
- [ ] Portal templates (base, overview, location).
- [ ] Minimal admin: create client + mint/revoke access link (small `/admin`
page or a script for now).
### M2 — Dashboard + alerts
- Richer client dashboard (multi-location at-a-glance, status rollup).
- **Live project map** — upgrade the overview's basic location pins into a real
project map: pins colored by measuring/level, popups showing each location's
current reading, centered/zoomed to the project. (M1 ships the plain pin map;
this makes it a live status map.)
- Surface each location's **threshold-alert status** (read-only) + an event/inbox
view. Leans on the SLMM alert engine + dispatch.
### Notes carried from M1
- Tile headline metric is **Leq** (energy-average, the sound-monitoring compliance
metric) — chosen over the twitchy instantaneous Lp. If clients ever want a
different headline (e.g. Lmax for peaks), make it a per-deployment setting.
### M3 — Reports
- Client-facing list + download of the daily baseline-comparison reports.
- Depends on the FTP report pipeline (`feat/ftp-report-pipeline`) landing and
being wired into the portal's scoped routes.
### M4 — Full auth system
- Replace the interim token behind `get_current_client` with a real auth design:
magic-link (passwordless email) and/or accounts, proper sessions, password
reset, and likely auth for the *internal* app too. Reverse-proxy + TLS posture.
## Going to prod (M1)
1. **Run the migration on the prod DB**`migrate_add_client_portal.py` adds
`projects.client_id` (the new tables auto-create via `create_all`). Skipping it
500s anything that touches `Project.client_id`. This is the silent killer.
```bash
docker compose exec web-app python3 backend/migrate_add_client_portal.py
```
2. **Set a real `SECRET_KEY`** in the prod env (compose). The portal signs session
cookies with it; the insecure dev default (it logs a warning at boot) is
forgeable. Non-negotiable for an internet-facing portal.
3. **SLMM_BASE_URL** — prod base compose already points at `:8100` (correct; the
`:9100` mismatch is a dev-only override quirk). For full live data (L1/L10 +
chart backfill) prod SLMM must be on the `dev` build with its migrations
(`migrate_add_ln_percentiles`, `migrate_add_monitor_enabled`) and **keepalive on**
for the client's units — otherwise the portal degrades gracefully (cards show
`--`, chart empty), it just isn't fully populated.
4. **Seed real clients** with the CLI (`backend/portal_admin.py`): `create-client`
`link-project` (a real sound project with an active SLM assignment) →
`mint-link` → send the client the printed URL (shown once).
5. **Exposure** — portal routes are auth-gated, but port 8001 still serves the
whole *internal* app with no auth. Before real clients are on it, the portal
should sit behind the reverse proxy with only `/portal/*` exposed (or the app
restricted). This is the point where the parked reverse-proxy/TLS work becomes
load-bearing.
## Security notes
- Portal is auth-gated from day one (even the interim gate) — never wide-open like
the internal app.
- All scoping enforced server-side; client-supplied ids are always re-checked.
- `SECRET_KEY` must be a real secret in prod (env, not committed).
- Cookies: `HttpOnly`, `SameSite=Lax`, `Secure` once behind TLS.
- Tokens stored hashed; raw shown once. Revocation is immediate.
## Security hardening backlog ("Fest 2026")
The to-do for the dedicated hardening pass, roughly highest-impact first. Until
then the portal runs on security-by-obscurity (open port + interim links) — fine
for a not-in-use demo, not for real clients.
**Exposure (the big one):** port 8001 serves the *entire operator app* (roster,
projects, `/admin/*`, device config, the SLMM proxy) with **zero auth**, so an
open port exposes far more than the read-only portal.
- [ ] Reverse proxy (NPM/Caddy/Nginx) in front, exposing **only `/portal/*`** to
the internet; keep the operator app reachable on the LAN only.
- [ ] TLS everywhere (Let's Encrypt). Then set portal cookies `Secure`.
- [ ] Don't port-forward the raw app; if a quick gate is wanted before M4, an
auth proxy (Authelia / Authentik) can front the portal without writing auth.
**Config musts:**
- [ ] Set a real `SECRET_KEY` env (signs session cookies; default is public).
- [ ] `PORTAL_OPEN_LINKS=false` in any internet-facing env (it defaults off now).
**M4 — real auth** (replaces the interim token behind `get_current_client`):
- [ ] Magic-link email and/or accounts; proper sessions + password reset.
- [ ] Authenticate the **operator** app too (it currently has none).
- [ ] Gate the operator-only endpoints that are presently unauthenticated:
`/projects/{id}/portal-preview`, `/projects/{id}/portal-link*`,
`/portal/open/*`.
**Smaller items from the pre-merge code review:**
- [ ] Keepalive isn't auto-turned-off when the last alert rule on a unit is
deleted (intentional "never auto-off"; revisit if it wastes cellular).
- [ ] Consider rate-limiting the scoped portal endpoints once public.
+67
View File
@@ -0,0 +1,67 @@
# Terra-View Roadmap
Living document — captures known deferred work, in-flight initiatives, and longer-term ideas.
Bump items up/down or strike them through as priorities shift. Source of truth for "what's next"
should be this file plus the `## Current Development Focus` block in `CLAUDE.md`.
Last updated: 2026-06-05 (Terra-View v0.13.3)
---
## In Flight
Work that's started or has obvious next steps in the code.
- **SFM Integration Phase 2 — device control** — expose `/device/*` (start, stop, erase, push-config)
through the Terra-View proxy. Blocked on SFM growing an auth layer; placeholder TODOs already in
`backend/services/device_controller.py` (lines 73, 109, 207, 282, 582).
- **Calibration sync from SFM events** — done in v0.13.x. Daily 03:15 job + Settings "Sync now" button.
Future: surface "last sync" timestamp on unit detail; per-unit "sync this one" action.
- **Synology NAS deployment** — doc lives at `docs/SYNOLOGY_DEPLOYMENT.md`. Need to actually deploy
+ write up what tripped us up vs. the doc's expectations.
## Near-Term
Concrete things scoped but not started.
- **Migrate GPS coord parse in `photos.py`** — currently writes to dead `RosterUnit.coordinates`
field. Should write to the active `MonitoringLocation` instead (matches the location-as-truth
refactor done elsewhere). Helper: `backend/services/unit_location.py`.
- **Phase 3 — drag-to-resize deployment bars** on the fleet-wide deployment-history Gantt
(`/tools/deployment-history`). Phase 2 (the calendar + Gantt tabs) shipped in v0.12.0.
- **Phase 5c — swap-detection daily job** — placeholder card already in `templates/tools.html:162`.
Auto-detects unit swaps in the field (BE12345 → BE67890 at the same project+location) from
operator-typed metadata. Pairs with a notification inbox.
- **Geocoding for address strings** — TODO in `templates/dashboard.html:913`. Lets locations without
explicit coordinates still appear on maps.
- **ModemManager backend**`backend/routers/modem_dashboard.py:279` has a TODO for querying a real
modem backend. Currently the modem dashboard is mostly read-only metadata.
## Medium-Term
Bigger features, sketched but not designed in detail.
- **Alerting** — email/SMS for missing units, calibration-expiring-soon, sync failures.
README's "Future Enhancements" has had this for a while; would pair well with the existing
`UserPreferences` thresholds.
- **Multi-user auth** — currently single-tenant, no login. Probably the prerequisite for any
cloud-hosted multi-customer deployment.
- **Notification inbox** — central place for swap-detection alerts, sync errors, calibration
warnings, FT-flag review queue, etc.
- **Audit log UI**`UnitHistory` already records everything; expose a filterable view.
## Long-Term / Wishlist
Speculative. Promote up the list once there's a concrete need.
- PostgreSQL backend for larger deployments (SQLite is fine for now)
- Advanced filtering / saved searches on roster + events
- Export roster in additional formats (XLSX, GeoJSON)
- Public-facing project status pages (read-only, share-link gated)
- SLM module parity with seismographs — modal-based event/measurement detail similar to SFM modal
- Weather station / accelerometer / GPS tracker modules (new device-type modules following the
SLMM pattern — see `CLAUDE.md` → "Adding a New Device Type Module")
## Done / Reference
For shipped items, see `CHANGELOG.md`. For architecture decisions, see `CLAUDE.md`.
+1
View File
@@ -9,3 +9,4 @@ Pillow==10.1.0
httpx==0.25.2 httpx==0.25.2
openpyxl==3.1.2 openpyxl==3.1.2
rapidfuzz==3.10.1 rapidfuzz==3.10.1
schedule==1.2.2
+66 -1
View File
@@ -42,6 +42,18 @@
</div> </div>
</div> </div>
<!-- Live Monitoring (keepalive) -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-1">Live Monitoring (keepalive)</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Keepalive runs the 1&nbsp;Hz DOD feed 24/7 (even with no viewer), which powers the live-chart
trail and continuous threshold alerts. Toggling persists and survives restarts.
</p>
<div id="monitor-list" class="text-sm">
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
</div>
</div>
<!-- Raw API tester --> <!-- Raw API tester -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6"> <div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2> <h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
@@ -132,7 +144,60 @@ async function sendRaw() {
} }
} }
async function loadMonitors() {
const el = document.getElementById('monitor-list');
try {
const r = await fetch('/api/slmm/roster');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const devices = d.devices || [];
if (!devices.length) {
el.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No devices configured.</p>';
return;
}
el.innerHTML = devices.map(dev => {
const on = !!dev.monitor_enabled;
const reach = dev.status ? dev.status.is_reachable : null;
const reachDot = reach === false
? '<span class="w-2 h-2 rounded-full bg-red-500 inline-block" title="unreachable"></span>'
: '<span class="w-2 h-2 rounded-full bg-green-500 inline-block" title="reachable"></span>';
return `
<div class="flex items-center justify-between border-b border-gray-100 dark:border-gray-700 py-2">
<div class="flex items-center gap-2">
${reachDot}
<span class="font-mono text-gray-900 dark:text-white">${_esc(dev.unit_id)}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(dev.host)}:${_esc(dev.tcp_port)}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs font-medium px-2 py-0.5 rounded ${on
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400'}">${on ? '24/7 ON' : 'OFF'}</span>
<button onclick="toggleMonitor('${_esc(dev.unit_id)}', ${!on})"
class="px-3 py-1 text-xs rounded text-white ${on
? 'bg-red-600 hover:bg-red-700' : 'bg-seismo-orange hover:bg-orange-600'}">
${on ? 'Disable' : 'Enable'}
</button>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = `<p class="text-red-600 dark:text-red-400">Failed to load devices: ${_esc(e.message)}</p>`;
}
}
async function toggleMonitor(unitId, enable) {
const action = enable ? 'start' : 'stop';
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/monitor/${action}`, { method: 'POST' });
if (!r.ok) throw new Error('HTTP ' + r.status);
await loadMonitors();
} catch (e) {
alert('Toggle failed: ' + e.message);
}
}
loadSlmmOverview(); loadSlmmOverview();
setInterval(loadSlmmOverview, 30000); loadMonitors();
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
</script> </script>
{% endblock %} {% endblock %}
+86 -51
View File
@@ -150,46 +150,55 @@ setInterval(_refreshPendingDeployBanner, 30000);
</svg> </svg>
</div> </div>
</div> </div>
<div class="space-y-3 card-content" id="fleet-summary-content"> <div class="space-y-4 card-content" id="fleet-summary-content">
<div class="flex justify-between items-center"> <!-- Seismographs -->
<span class="text-gray-600 dark:text-gray-400">Total Units</span> <div>
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span> <div class="flex justify-between items-center mb-1.5">
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Benched</span>
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
<div class="flex justify-between items-center mb-1">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg> </svg>
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a> <a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
</div> </div>
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span> <span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
</div> </div>
<div class="flex justify-between items-center mb-2"> <div class="pl-6 flex flex-col gap-0.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
</div>
</div>
<!-- Sound Level Meters -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg> </svg>
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a> <a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
</div> </div>
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span> <span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
</div>
<div class="pl-6 flex flex-col gap-0.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div> </div>
</div> </div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"> <div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p> <p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)"> <div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
<div class="flex items-center"> <div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center"> <span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
@@ -628,9 +637,14 @@ function updateFleetMapFiltered(allUnits) {
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker)); fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = []; fleetMarkers = [];
// Get deployed units with coordinates that pass the filter // Get deployed units with coordinates that pass the filter.
// Modems are not plotted — they inherit the paired device's location,
// which would just stack a duplicate marker on the same pin.
const deployedUnits = Object.entries(allUnits || {}) const deployedUnits = Object.entries(allUnits || {})
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u)); .filter(([_, u]) => u.deployed
&& u.coordinates
&& (u.device_type || 'seismograph') !== 'modem'
&& unitPassesFilter(u));
if (deployedUnits.length === 0) { if (deployedUnits.length === 0) {
return; return;
@@ -672,10 +686,12 @@ function updateFleetMapFiltered(allUnits) {
// Popup with device type // Popup with device type
const deviceLabel = getDeviceTypeLabel(deviceType); const deviceLabel = getDeviceTypeLabel(deviceType);
const locName = unit.location_name || '';
marker.bindPopup(` marker.bindPopup(`
<div class="p-2"> <div class="p-2">
<h3 class="font-bold text-lg">${id}</h3> <h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm text-gray-600">${deviceLabel}</p> <p class="text-sm text-gray-600">${deviceLabel}</p>
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p> <p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''} ${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a> <a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
@@ -783,32 +799,51 @@ function updateDashboard(event) {
timeZoneName: 'short' timeZoneName: 'short'
}); });
// ===== Fleet summary numbers (always unfiltered) ===== // ===== Fleet Summary: per-device-type counts (always unfiltered) =====
document.getElementById('total-units').textContent = data.summary?.total ?? 0; // Deployed = unit has an active UnitAssignment (location_id set by
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0; // the snapshot helper). Benched = no active assignment.
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0; // Retired, out-for-calibration, and roster-unknown units (emitters
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0; // not in the roster) are excluded from totals.
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0; const counts = {
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0; seismograph: { total: 0, deployed: 0, benched: 0 },
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0; sound_level_meter: { total: 0, deployed: 0, benched: 0 },
};
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
const unknownIds = new Set(Object.keys(data.unknown || {}));
// ===== Device type counts (always unfiltered) ===== Object.entries(data.units || {}).forEach(([uid, unit]) => {
let seismoCount = 0; if (unit.retired || unit.out_for_calibration) return;
let slmCount = 0; if (unknownIds.has(uid)) return;
let modemCount = 0; const dt = unit.device_type || 'seismograph';
Object.values(data.units || {}).forEach(unit => { const bucket = counts[dt];
if (unit.retired) return; // Don't count retired units if (!bucket) return; // skip modems and anything else
const deviceType = unit.device_type || 'seismograph';
if (deviceType === 'seismograph') { bucket.total++;
seismoCount++; if (unit.location_id) {
} else if (deviceType === 'sound_level_meter') { bucket.deployed++;
slmCount++; } else {
} else if (deviceType === 'modem') { bucket.benched++;
modemCount++; }
// Status tally only for seismographs + SLMs that are actually
// deployed (assigned). Mirrors the per-device buckets so the
// sum matches.
if (unit.location_id) {
if (unit.status === 'OK') monitoredOk++;
else if (unit.status === 'Pending') monitoredPending++;
else if (unit.status === 'Missing') monitoredMissing++;
} }
}); });
document.getElementById('seismo-count').textContent = seismoCount;
document.getElementById('slm-count').textContent = slmCount; document.getElementById('seismo-count').textContent = counts.seismograph.total;
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
document.getElementById('status-ok').textContent = monitoredOk;
document.getElementById('status-pending').textContent = monitoredPending;
document.getElementById('status-missing').textContent = monitoredMissing;
// ===== Apply filters and render map + alerts ===== // ===== Apply filters and render map + alerts =====
renderFilteredDashboard(data); renderFilteredDashboard(data);
+24 -14
View File
@@ -2,7 +2,14 @@
{% if units %} {% if units %}
{% for unit in units %} {% for unit in units %}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative"> <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
<div class="absolute top-3 right-3 flex gap-2"> <div class="absolute top-3 right-3 flex gap-2 z-10">
<button onclick="event.preventDefault(); event.stopPropagation(); refreshSlmUnit('{{ unit.id }}', this);"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="Refresh {{ unit.id }} from device">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');" <button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="View live chart"> title="View live chart">
@@ -20,9 +27,8 @@
</button> </button>
</div> </div>
<a href="/slm/{{ unit.id }}" class="block"> <a href="/slm/{{ unit.id }}" class="block pr-24">
<div class="flex items-start justify-between gap-4"> <div class="min-w-0">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span> <span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
{% if unit.slm_model %} {% if unit.slm_model %}
@@ -36,25 +42,29 @@
{% endif %} {% endif %}
</div> </div>
<!-- Status badge + last-check on one line (moved off the top-right so it
no longer collides with the refresh/chart/gear action icons). -->
<div class="mt-2 flex items-center gap-2 flex-wrap">
{% if unit.retired %} {% if unit.retired %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span> <span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
{% elif not unit.deployed %} {% elif not unit.deployed %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span> <span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif unit.measurement_state == "Start" %} {% elif unit.measurement_state in ["Start", "Measure"] %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span> <span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
{% elif unit.is_recent %} {% elif unit.is_recent %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span> <span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
{% else %} {% else %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span> <span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
{% endif %} {% endif %}
</div> <span class="text-xs text-gray-500 dark:text-gray-400">
{% if unit.cache_last_seen %}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400"> Last check: {{ unit.cache_last_seen|local_datetime }}
{% if unit.slm_last_check %} {% elif unit.slm_last_check %}
Last check: {{ unit.slm_last_check|local_datetime }} Last check: {{ unit.slm_last_check|local_datetime }}
{% else %} {% else %}
No recent check-in No recent check-in
{% endif %} {% endif %}
</span>
</div> </div>
</a> </a>
</div> </div>
+113 -19
View File
@@ -143,6 +143,8 @@
</svg> </svg>
Stop Live Stream Stop Live Stream
</button> </button>
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
</div> </div>
</div> </div>
@@ -173,17 +175,17 @@
</div> </div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4"> <div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p> <p id="live-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln1_label %}{{ current_status.ln1_label }}{% else %}L1{% endif %}</p>
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400"> <p id="live-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %} {% if current_status and current_status.ln1 %}{{ current_status.ln1 }}{% else %}--{% endif %}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4"> <div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p> <p id="live-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">{% if current_status and current_status.ln2_label %}{{ current_status.ln2_label }}{% else %}L10{% endif %}</p>
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400"> <p id="live-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %} {% if current_status and current_status.ln2 %}{{ current_status.ln2 }}{% else %}--{% endif %}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
@@ -432,6 +434,24 @@ function initializeChart() {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0 pointRadius: 0
},
{
label: 'L1',
data: [],
borderColor: 'rgb(139, 92, 246)',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
},
{
label: 'L10',
data: [],
borderColor: 'rgb(245, 158, 11)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
} }
] ]
}, },
@@ -493,7 +513,37 @@ if (typeof window.currentWebSocket === 'undefined') {
window.currentWebSocket = null; window.currentWebSocket = null;
} }
function initLiveDataStream(unitId) { // Backfill the chart with the recent DOD trail so it opens with context.
async function backfillChart(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
if (!r.ok) return;
const d = await r.json();
const readings = d.readings || [];
if (!window.chartData) return;
for (const row of readings) {
// Trail timestamps are naive UTC; append 'Z' so they convert to local
// consistently with the live frames (which use local Date.now()).
window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
window.chartData.lp.push(parseFloat(row.lp || 0));
window.chartData.leq.push(parseFloat(row.leq || 0));
window.chartData.ln1.push(parseFloat(row.ln1 || 0));
window.chartData.ln2.push(parseFloat(row.ln2 || 0));
}
if (window.liveChart) {
window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none');
}
} catch (e) {
console.warn('Chart backfill failed:', e);
}
}
async function initLiveDataStream(unitId) {
// Close existing connection if any // Close existing connection if any
if (window.currentWebSocket) { if (window.currentWebSocket) {
window.currentWebSocket.close(); window.currentWebSocket.close();
@@ -504,17 +554,24 @@ function initLiveDataStream(unitId) {
window.chartData.timestamps = []; window.chartData.timestamps = [];
window.chartData.lp = []; window.chartData.lp = [];
window.chartData.leq = []; window.chartData.leq = [];
window.chartData.ln1 = [];
window.chartData.ln2 = [];
} }
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) { if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
window.liveChart.data.labels = []; window.liveChart.data.labels = [];
window.liveChart.data.datasets[0].data = []; window.liveChart.data.datasets.forEach(ds => ds.data = []);
window.liveChart.data.datasets[1].data = [];
window.liveChart.update(); window.liveChart.update();
} }
// WebSocket URL for SLMM backend via proxy // Seed the chart with recent history BEFORE opening the live socket, so live
// frames append after the backfill (right order) and the chart isn't blank.
await backfillChart(unitId);
// WebSocket URL for SLMM backend via proxy.
// /monitor = the shared fan-out DOD feed (many viewers, one device connection,
// and it carries L1/L10 which the DRD /stream cannot).
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`; const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`;
window.currentWebSocket = new WebSocket(wsUrl); window.currentWebSocket = new WebSocket(wsUrl);
@@ -530,7 +587,11 @@ function initLiveDataStream(unitId) {
window.currentWebSocket.onmessage = function(event) { window.currentWebSocket.onmessage = function(event) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log('WebSocket data received:', data); // The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a
// 'feed_status' on each frame. Reflect status, but don't let a heartbeat
// or an 'unreachable' frame blank the cards / spike the chart with zeros.
updateFeedStatus(data.feed_status);
if (data.heartbeat || data.feed_status === 'unreachable') return;
updateLiveMetrics(data); updateLiveMetrics(data);
updateLiveChart(data); updateLiveChart(data);
} catch (error) { } catch (error) {
@@ -559,6 +620,21 @@ function stopLiveDataStream() {
} }
} }
// Reflect device reachability from the monitor feed's feed_status. Safe no-op
// if the badge element isn't on the page.
function updateFeedStatus(status) {
const el = document.getElementById('live-feed-status');
if (!el || status == null) return;
if (status === 'unreachable') {
el.textContent = 'Device offline';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
} else {
el.textContent = 'Live';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
el.style.display = '';
}
// Update metrics display // Update metrics display
function updateLiveMetrics(data) { function updateLiveMetrics(data) {
if (document.getElementById('live-lp')) { if (document.getElementById('live-lp')) {
@@ -570,11 +646,20 @@ function updateLiveMetrics(data) {
if (document.getElementById('live-lmax')) { if (document.getElementById('live-lmax')) {
document.getElementById('live-lmax').textContent = data.lmax || '--'; document.getElementById('live-lmax').textContent = data.lmax || '--';
} }
if (document.getElementById('live-lmin')) { // Only update Ln values when the frame actually carries them. DRD stream
document.getElementById('live-lmin').textContent = data.lmin || '--'; // frames omit percentiles (DOD-only), so without this guard a live stream
// would blank L1/L10 over the values rendered from the cached DOD snapshot.
if (data.ln1 != null && document.getElementById('live-ln1')) {
document.getElementById('live-ln1').textContent = data.ln1;
} }
if (document.getElementById('live-lpeak')) { if (data.ln1_label && document.getElementById('live-ln1-label')) {
document.getElementById('live-lpeak').textContent = data.lpeak || '--'; document.getElementById('live-ln1-label').textContent = data.ln1_label;
}
if (data.ln2 != null && document.getElementById('live-ln2')) {
document.getElementById('live-ln2').textContent = data.ln2;
}
if (data.ln2_label && document.getElementById('live-ln2-label')) {
document.getElementById('live-ln2-label').textContent = data.ln2_label;
} }
} }
@@ -583,7 +668,9 @@ if (typeof window.chartData === 'undefined') {
window.chartData = { window.chartData = {
timestamps: [], timestamps: [],
lp: [], lp: [],
leq: [] leq: [],
ln1: [],
ln2: []
}; };
} }
@@ -593,12 +680,17 @@ function updateLiveChart(data) {
window.chartData.timestamps.push(now.toLocaleTimeString()); window.chartData.timestamps.push(now.toLocaleTimeString());
window.chartData.lp.push(parseFloat(data.lp || 0)); window.chartData.lp.push(parseFloat(data.lp || 0));
window.chartData.leq.push(parseFloat(data.leq || 0)); window.chartData.leq.push(parseFloat(data.leq || 0));
window.chartData.ln1.push(parseFloat(data.ln1 || 0));
window.chartData.ln2.push(parseFloat(data.ln2 || 0));
// Keep only last 60 data points // Keep a rolling window large enough to hold the ~2h backfill (one point/min)
if (window.chartData.timestamps.length > 60) { // plus a good run of live points before the oldest scroll off.
if (window.chartData.timestamps.length > 600) {
window.chartData.timestamps.shift(); window.chartData.timestamps.shift();
window.chartData.lp.shift(); window.chartData.lp.shift();
window.chartData.leq.shift(); window.chartData.leq.shift();
window.chartData.ln1.shift();
window.chartData.ln2.shift();
} }
// Update chart if available // Update chart if available
@@ -606,6 +698,8 @@ function updateLiveChart(data) {
window.liveChart.data.labels = window.chartData.timestamps; window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp; window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq; window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none'); window.liveChart.update('none');
} }
} }
+5 -3
View File
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
if (typeof checkFTPStatus === 'function') { if (typeof checkFTPStatus === 'function') {
checkFTPStatus(unitId); checkFTPStatus(unitId);
} }
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
htmx.trigger('#slm-list', 'load'); htmx.trigger('#slm-list', 'load');
} }
}, 1500); }, 1500);
@@ -604,8 +604,10 @@ async function toggleSLMDeployed() {
successDiv.classList.remove('hidden'); successDiv.classList.remove('hidden');
setTimeout(() => successDiv.classList.add('hidden'), 3000); setTimeout(() => successDiv.classList.add('hidden'), 3000);
// Refresh any SLM list on the page // Refresh any SLM list on the page (only if one is actually present —
if (typeof htmx !== 'undefined') { // the detail/dashboard pages have no #slm-list, and htmx.trigger on a
// null target throws "can't access property dispatchEvent, e is null").
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
htmx.trigger('#slm-list', 'load'); htmx.trigger('#slm-list', 'load');
} }
} catch (error) { } catch (error) {
+19
View File
@@ -0,0 +1,19 @@
{% extends "portal/base.html" %}
{% block title %}Access{% endblock %}
{% block content %}
<div class="max-w-md mx-auto mt-20 text-center reveal">
<div class="panel inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6">
<svg class="w-7 h-7 text-[var(--text-dim)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
{% if reason == "invalid" %}
<h1 class="text-2xl font-bold tracking-tight mb-2">This link isn't valid</h1>
<p class="text-[var(--text-dim)] text-sm leading-relaxed">The access link is expired or has been revoked.<br>Please contact TMI for a new link.</p>
{% else %}
<h1 class="text-2xl font-bold tracking-tight mb-2">Access link required</h1>
<p class="text-[var(--text-dim)] text-sm leading-relaxed">Open the monitoring link TMI sent you to view your locations.</p>
{% endif %}
</div>
{% endblock %}
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Monitoring{% endblock %} · TMI</title>
<!-- apply saved theme before paint (no flash); light is the default -->
<script>(function(){var t=localStorage.getItem('portal-theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: { extend: {
colors: { seismo: { orange: '#f48b1c', navy: '#142a66', burgundy: '#7d234d' } },
fontFamily: {
sans: ['"Hanken Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
} }
}
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
<meta name="theme-color" content="#eef2f9">
<style>
/* ---- dark (default) ---- */
:root {
--bg: #080b14;
--grid: rgba(124, 146, 188, 0.045);
--aurora-1: rgba(20, 42, 102, 0.55);
--aurora-2: rgba(125, 35, 77, 0.18);
--text: #e7ecf6;
--text-dim: #8c98b0;
--border: rgba(124, 146, 188, 0.14);
--border-bright: rgba(168, 188, 224, 0.30);
--panel-a: rgba(24, 33, 54, 0.72);
--panel-b: rgba(12, 18, 31, 0.62);
--panel-inset: rgba(255, 255, 255, 0.05);
--panel-shadow: 0 22px 48px -28px rgba(0, 0, 0, 0.85);
--header-bg: rgba(8, 11, 20, 0.72);
--accent: #f48b1c;
--accent-glow: rgba(244, 139, 28, 0.40);
--lvl-ok: #34d399; --lvl-warn: #fbbf24; --lvl-bad: #f87171;
--m-lp: #60a5fa; --m-lmax: #f87171; --m-l1: #c084fc; --m-l10: #fbbf24;
}
/* ---- light (cool) — solid cards on a cool ground ---- */
html[data-theme="light"] {
--bg: #eef2f9; /* cool light */
--grid: rgba(20, 42, 102, 0.05); /* cool faint grid */
--aurora-1: rgba(120, 150, 220, 0.18); /* cool wash */
--aurora-2: rgba(244, 139, 28, 0.08); /* faint brand accent */
--text: #16203a; /* cool navy ink */
--text-dim: #5d6b86; /* cool muted */
--border: rgba(20, 42, 102, 0.13);
--border-bright: rgba(20, 42, 102, 0.18);
--panel-a: #ffffff; /* solid — kept from the un-ghosting pass */
--panel-b: #f7f9fc;
--panel-inset: rgba(255, 255, 255, 0.9);
--panel-shadow: 0 14px 30px -16px rgba(40, 55, 95, 0.22), 0 2px 6px -2px rgba(40, 55, 95, 0.07);
--header-bg: rgba(238, 242, 249, 0.85);
--lvl-ok: #16a34a; --lvl-warn: #d97706; --lvl-bad: #dc2626;
--m-lp: #2563eb; --m-lmax: #dc2626; --m-l1: #9333ea; --m-l10: #d97706;
}
/* On light, the hover-lift shadow wants cool depth (the dark one vanishes on light). */
html[data-theme="light"] .panel-hover:hover {
box-shadow: 0 22px 44px -20px rgba(40, 55, 95, 0.26), 0 0 0 1px var(--accent-glow);
}
html, body { height: 100%; }
body {
margin: 0;
color: var(--text);
font-family: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;
font-feature-settings: "ss01";
background-color: var(--bg);
background-image:
radial-gradient(1100px 560px at 50% -12%, var(--aurora-1), transparent 68%),
radial-gradient(700px 400px at 88% 8%, var(--aurora-2), transparent 70%),
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: auto, auto, 46px 46px, 46px 46px;
background-attachment: fixed;
transition: background-color .3s ease, color .3s ease;
}
::selection { background: rgba(244, 139, 28, 0.30); }
.font-mono, .reading { font-family: "IBM Plex Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.panel {
position: relative;
background: linear-gradient(180deg, var(--panel-a), var(--panel-b));
border: 1px solid var(--border);
border-radius: 16px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 1px 0 var(--panel-inset) inset, var(--panel-shadow);
}
.panel::before {
content: ''; position: absolute; inset: 0 0 auto 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--border-bright), transparent);
}
.panel-hover { transition: transform .22s ease, border-color .22s ease, box-shadow .22s ease; }
.panel-hover:hover {
transform: translateY(-3px);
border-color: rgba(244, 139, 28, 0.55);
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--accent-glow);
}
.hairline { border-top: 1px solid var(--border); }
/* metric accent colors (flip per theme) */
.c-lp { color: var(--m-lp); } .c-lmax { color: var(--m-lmax); }
.c-l1 { color: var(--m-l1); } .c-l10 { color: var(--m-l10); }
.live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 0 var(--accent-glow); animation: pulse 2.2s infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 var(--accent-glow); } 70% { box-shadow: 0 0 0 9px rgba(244, 139, 28, 0); } 100% { box-shadow: 0 0 0 0 rgba(244, 139, 28, 0); } }
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.reveal { opacity: 0; animation: rise .55s cubic-bezier(.2, .7, .2, 1) forwards; }
.signal-bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
.signal-bars i { width: 3px; background: var(--accent); border-radius: 1px; animation: bars 1.4s ease-in-out infinite; }
.signal-bars i:nth-child(1) { height: 40%; } .signal-bars i:nth-child(2) { height: 70%; animation-delay: .2s; }
.signal-bars i:nth-child(3) { height: 100%; animation-delay: .4s; } .signal-bars i:nth-child(4) { height: 55%; animation-delay: .6s; }
@keyframes bars { 0%, 100% { transform: scaleY(.5); opacity: .7; } 50% { transform: scaleY(1); opacity: 1; } }
.theme-toggle { color: var(--text-dim); transition: color .2s ease, background .2s ease; }
.theme-toggle:hover { color: var(--text); }
html[data-theme="light"] .moon { display: none; }
html[data-theme="dark"] .sun, :root:not([data-theme="light"]) .sun { display: none; }
/* Leaflet polish (dark default; .leaflet-light tweaks tooltip for light) */
.leaflet-container { background: var(--bg) !important; }
.leaflet-tooltip { background: var(--panel-a); border: 1px solid var(--border-bright); color: var(--text); box-shadow: none; font-family: inherit; font-size: 12px; }
.leaflet-tooltip-top::before { border-top-color: var(--border-bright); }
.leaflet-control-attribution { background: rgba(0,0,0,0.25) !important; color: var(--text-dim) !important; }
.leaflet-control-attribution a { color: var(--text-dim) !important; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: rgba(124, 146, 188, 0.22); border-radius: 9px; }
</style>
{% block head %}{% endblock %}
</head>
<body class="min-h-full antialiased">
<header class="sticky top-0 z-30 border-b border-[var(--border)] bg-[var(--header-bg)] backdrop-blur-xl">
<div class="max-w-5xl mx-auto px-5 py-3.5 flex items-center justify-between">
<a href="/portal" class="flex items-center gap-2.5">
<span class="signal-bars"><i></i><i></i><i></i><i></i></span>
<span class="font-semibold tracking-tight text-[15px]">
TMI <span class="text-[var(--text-dim)] font-normal">Monitoring</span>
{% if client %}<span class="text-[var(--text-dim)] font-normal mx-0.5">/</span>
<span class="text-seismo-orange">{{ client.name }}</span>{% endif %}
</span>
</a>
<div class="flex items-center gap-1.5">
<button onclick="togglePortalTheme()" class="theme-toggle p-2 rounded-lg" title="Toggle light / dark" aria-label="Toggle theme">
<svg class="moon w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
<svg class="sun w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</button>
{% if client %}
<a href="/portal/logout" class="text-[13px] text-[var(--text-dim)] hover:text-[var(--text)] transition-colors px-2">Sign out</a>
{% endif %}
</div>
</div>
</header>
<main class="max-w-5xl mx-auto px-5 py-8">
{% block content %}{% endblock %}
</main>
<footer class="max-w-5xl mx-auto px-5 py-10 text-[11px] text-[var(--text-dim)] flex items-center gap-2 opacity-70">
<span class="w-1 h-1 rounded-full bg-[var(--text-dim)]"></span>
Read-only monitoring view · data provided as-is for informational purposes
</footer>
<script>
// Theme toggle. Pages can listen for 'portal-theme' to re-skin canvases/maps.
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
// HTML-escape operator-set strings (location/rule names) before innerHTML/tooltip injection.
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
function togglePortalTheme() {
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const next = cur === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('portal-theme', next); } catch (e) {}
const mc = document.querySelector('meta[name="theme-color"]');
if (mc) mc.setAttribute('content', next === 'light' ? '#eef2f9' : '#080b14');
document.dispatchEvent(new CustomEvent('portal-theme', { detail: next }));
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+335
View File
@@ -0,0 +1,335 @@
{% extends "portal/base.html" %}
{% block title %}{{ location.name }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<a href="/portal" class="reveal inline-flex items-center gap-1.5 text-sm text-[var(--text-dim)] hover:text-[var(--text)] transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
All locations
</a>
<div class="reveal mt-3 flex flex-wrap items-end justify-between gap-3">
<h1 class="text-3xl font-bold tracking-tight">{{ location.name }}</h1>
<div class="flex items-center gap-2.5">
<span id="p-badge" class="hidden"></span>
<span id="p-fresh" class="text-[var(--text-dim)] font-mono text-xs"></span>
</div>
</div>
{% if not has_device %}
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No device is currently assigned to this location.</div>
{% else %}
<div id="p-alarm-banner" class="hidden reveal mt-5 px-4 py-3 rounded-xl bg-[rgba(220,38,38,0.10)] border border-[rgba(220,38,38,0.32)] text-[var(--lvl-bad)] text-sm flex items-center gap-2.5">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/>
</svg>
<span id="p-alarm-text" class="font-medium">Currently above threshold.</span>
</div>
<!-- Hero console: Leq primary + instrument strip -->
<div class="panel reveal mt-5 p-6 sm:p-7" style="animation-delay:60ms">
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-1.5">Leq · average</div>
<div class="flex items-baseline gap-2.5">
<span id="p-leq" class="reading text-6xl sm:text-7xl leading-none font-semibold">--</span>
<span class="text-sm text-[var(--text-dim)] font-mono">dB</span>
</div>
<div class="hairline mt-6 pt-5 grid grid-cols-2 sm:grid-cols-4 gap-5">
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lp · instant</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-lp" class="reading text-2xl font-semibold c-lp">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lmax · peak</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-lmax" class="reading text-2xl font-semibold c-lmax">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L1</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln1" class="reading text-2xl font-semibold c-l1">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L10</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln2" class="reading text-2xl font-semibold c-l10">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
</div>
</div>
<!-- Live trace -->
<div class="panel reveal mt-5 overflow-hidden" style="animation-delay:120ms">
<div class="px-5 pt-4 text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono">Live trace · last 2h</div>
<div class="relative px-3 pb-3 pt-2" style="min-height: 340px;">
<canvas id="p-chart"></canvas>
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-[rgba(8,11,20,0.78)] rounded-xl backdrop-blur-sm">
<button onclick="resumeStream()"
class="px-4 py-2 rounded-lg bg-seismo-orange/15 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/25 text-sm font-medium transition-colors">
&#9208; Live paused — tap to resume
</button>
</div>
</div>
</div>
<!-- Alert limits (what this location is alerted on) -->
<div id="p-limits-section" class="reveal mt-7 hidden" style="animation-delay:180ms">
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert limits</div>
<div id="p-thresholds" class="space-y-2"></div>
</div>
<!-- Alert history -->
<div class="reveal mt-7" style="animation-delay:220ms">
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert history</div>
<div id="p-events" class="space-y-2"></div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{% if has_device %}
<script>
const LOC_ID = "{{ location.id }}";
const cd = { t: [], lp: [], leq: [], ln1: [], ln2: [] };
let chart;
const numOrNull = v => { const f = parseFloat(v); return isNaN(f) ? null : f; };
// Level color for the Leq hero (matches the overview bands).
const LEVEL_AMBER = 55, LEVEL_RED = 70;
function leqColor(measuring, v) {
// CSS var refs so the hero color auto-flips with the theme.
if (!measuring || v == null || isNaN(v)) return 'var(--text)';
if (v >= LEVEL_RED) return 'var(--lvl-bad)';
if (v >= LEVEL_AMBER) return 'var(--lvl-warn)';
return 'var(--lvl-ok)';
}
function paintLeq(measuring, leqVal) {
const el = document.getElementById('p-leq');
if (el) el.style.color = leqColor(measuring, parseFloat(leqVal));
}
function ds(label) { return { label, data: [], borderWidth: 1.5, pointRadius: 0, tension: 0.35, spanGaps: true }; }
function skinChart() {
if (!chart) return;
const dim = cssVar('--text-dim');
const cols = [cssVar('--m-lp'), cssVar('--lvl-ok'), cssVar('--m-l1'), cssVar('--m-l10')];
chart.data.datasets.forEach((d, i) => { d.borderColor = cols[i]; d.backgroundColor = cols[i]; });
const grid = 'rgba(124,146,188,0.10)', gridX = 'rgba(124,146,188,0.05)', border = 'rgba(124,146,188,0.18)';
const y = chart.options.scales.y, x = chart.options.scales.x;
y.ticks.color = dim; y.title.color = dim; y.grid.color = grid; y.border.color = border;
x.ticks.color = dim; x.grid.color = gridX; x.border.color = border;
chart.options.plugins.legend.labels.color = cssVar('--text');
chart.update('none');
}
function initChart() {
const ctx = document.getElementById('p-chart').getContext('2d');
const mono = { family: 'IBM Plex Mono', size: 10 };
chart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [ds('Lp'), ds('Leq'), ds('L1'), ds('L10')] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
interaction: { intersect: false, mode: 'index' },
scales: {
y: { min: 30, max: 130, title: { display: true, text: 'dB', font: { family: 'IBM Plex Mono' } },
ticks: { font: mono }, grid: {}, border: {} },
x: { ticks: { font: mono, maxTicksLimit: 8 }, grid: {}, border: {} }
},
plugins: { legend: { labels: { font: { family: 'Hanken Grotesk' }, usePointStyle: true, pointStyleWidth: 10, boxHeight: 7 } } }
}
});
skinChart();
}
document.addEventListener('portal-theme', skinChart);
function setCard(id, v) { document.getElementById(id).textContent = (v == null || v === '') ? '--' : v; }
function setBadge(measuring, lastSeen) {
const b = document.getElementById('p-badge'), f = document.getElementById('p-fresh');
const base = 'inline-flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded-full border ';
if (measuring === null) { b.className = 'hidden'; b.textContent = ''; }
else if (measuring) { b.className = base + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; b.innerHTML = '<span class="live-dot"></span> Live'; }
else { b.className = base + 'border-[var(--border)] text-[var(--text-dim)]'; b.textContent = 'Stopped'; }
f.innerHTML = fmtFreshness(lastSeen);
}
function fmtFreshness(iso) {
if (!iso) return '<span class="text-[var(--text-dim)]">no recent reading</span>';
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
let ago, stale = false;
if (s < 10) ago = 'just now';
else if (s < 60) ago = s + 's ago';
else if (s < 3600) { ago = Math.round(s / 60) + 'm ago'; stale = s >= 300; }
else { ago = Math.round(s / 3600) + 'h ago'; stale = true; }
const cls = stale ? 'text-amber-400' : 'text-[var(--text-dim)]';
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${stale ? ' · cached' : ''})</span>`;
}
async function prefill() {
try {
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/live`)).json();
const d = j.data;
if (!d) {
setBadge(null, null);
document.getElementById('p-fresh').textContent =
j.reason === 'no_device' ? 'No device assigned' : 'Currently unreachable';
return;
}
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setBadge(measuring, d.last_seen);
paintLeq(measuring, d.leq);
} catch (e) { /* keep last values */ }
}
async function backfill() {
try {
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/history?hours=2`)).json();
for (const row of (j.readings || [])) {
cd.t.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
cd.lp.push(numOrNull(row.lp)); cd.leq.push(numOrNull(row.leq));
cd.ln1.push(numOrNull(row.ln1)); cd.ln2.push(numOrNull(row.ln2));
}
chart.data.labels = cd.t;
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
chart.update('none');
} catch (e) { /* leave chart empty */ }
}
// ---- live stream (upgrades the cache prefill to a real ~1Hz feed) --------
let ws = null, hardCap = null, paused = false;
const IDLE_CAP_MS = 15 * 60 * 1000; // auto-close after 15 min so an abandoned
// tab doesn't pin the device at 1Hz polling
function pushPoint(d) {
cd.t.push(new Date().toLocaleTimeString());
cd.lp.push(numOrNull(d.lp)); cd.leq.push(numOrNull(d.leq));
cd.ln1.push(numOrNull(d.ln1)); cd.ln2.push(numOrNull(d.ln2));
if (cd.t.length > 600) { cd.t.shift(); cd.lp.shift(); cd.leq.shift(); cd.ln1.shift(); cd.ln2.shift(); }
chart.data.labels = cd.t;
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
chart.update('none');
}
function openStream() {
if (paused || ws) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/portal/api/location/${encodeURIComponent(LOC_ID)}/stream`);
ws.onmessage = (e) => {
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
if (d.feed_status === 'no_device') {
setBadge(null, null);
document.getElementById('p-fresh').textContent = 'No device assigned';
return;
}
if (d.heartbeat) return;
if (d.feed_status === 'unreachable') {
document.getElementById('p-fresh').innerHTML = '<span class="text-amber-400">device unreachable</span>';
return;
}
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setBadge(measuring, d.timestamp || new Date().toISOString());
paintLeq(measuring, d.leq);
pushPoint(d);
};
ws.onclose = () => { ws = null; };
ws.onerror = () => {};
clearTimeout(hardCap);
hardCap = setTimeout(() => { paused = true; closeStream(); showPaused(true); }, IDLE_CAP_MS);
}
function closeStream() {
clearTimeout(hardCap);
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
}
function showPaused(on) {
const el = document.getElementById('p-paused');
if (el) el.classList.toggle('hidden', !on);
}
function resumeStream() {
paused = false; showPaused(false);
prefill(); // refresh cards instantly on resume
openStream();
}
// Stop streaming when the tab is hidden (client switched away / locked phone) and
// resume when it's visible again — the main cost guard, so the device relaxes back
// to its idle poll rate the moment nobody is actually looking.
document.addEventListener('visibilitychange', () => {
if (document.hidden) closeStream();
else if (!paused) openStream();
});
window.addEventListener('beforeunload', closeStream);
// ---- alert history + current-alarm banner (read-only) --------------------
const EV_METRIC = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
function fmtAlertTime(iso) { return iso ? new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString() : ''; }
// ---- alert limits (the active thresholds, read-only) ---------------------
function fmtThreshold(r) {
const m = EV_METRIC[r.metric] || esc(r.metric);
const cmp = r.comparison === 'below' ? 'below' : 'above';
let s = `${m} ${cmp} ${r.threshold_db} dB`;
if (r.duration_s) s += ` for ${r.duration_s}s`;
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}${r.schedule_end}`;
return s;
}
async function loadThresholds() {
const sec = document.getElementById('p-limits-section');
try {
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/thresholds`)).json();
const rules = j.rules || [];
if (!rules.length) { sec.classList.add('hidden'); return; }
const list = document.getElementById('p-thresholds');
list.innerHTML = '';
for (const r of rules) {
const row = document.createElement('div');
row.className = 'panel px-3.5 py-2.5 text-sm flex items-center gap-2.5';
row.innerHTML = `<span class="w-1.5 h-1.5 rounded-full bg-seismo-orange shrink-0"></span>
<span class="text-[var(--text)]">${esc(r.name || 'Alert')}</span>
<span class="text-[var(--text-dim)] font-mono text-xs">${fmtThreshold(r)}</span>`;
list.appendChild(row);
}
sec.classList.remove('hidden');
} catch (e) { sec.classList.add('hidden'); }
}
async function loadEvents() {
try {
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json();
const events = j.events || [];
const banner = document.getElementById('p-alarm-banner');
if (j.active) {
banner.classList.remove('hidden');
document.getElementById('p-alarm-text').textContent =
j.active > 1 ? `${j.active} alerts currently active` : 'Currently above threshold.';
} else banner.classList.add('hidden');
const list = document.getElementById('p-events');
if (!events.length) { list.innerHTML = '<div class="text-sm text-[var(--text-dim)]">No alerts have fired.</div>'; return; }
list.innerHTML = '';
for (const e of events) {
const m = EV_METRIC[e.metric] || esc(e.metric);
const active = e.status === 'active';
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
const row = document.createElement('div');
row.className = 'panel px-3.5 py-2.5 text-sm ' + (active ? 'border-[rgba(220,38,38,0.4)]' : '');
row.innerHTML = `<div class="${active ? 'text-[var(--lvl-bad)] font-medium' : 'text-[var(--text)]'}">${esc(e.rule_name || 'Alert')} <span class="text-xs text-[var(--text-dim)] font-mono">· ${m} ${e.threshold_db} dB</span></div>
<div class="text-xs text-[var(--text-dim)] font-mono mt-0.5">${when}${peak}</div>`;
list.appendChild(row);
}
} catch (e) { /* leave history as-is */ }
}
initChart();
prefill(); // instant first paint from cache
backfill(); // seed the chart trail
openStream(); // then upgrade to the live feed
loadEvents();
loadThresholds();
setInterval(loadEvents, 20000);
</script>
{% endif %}
{% endblock %}
+192
View File
@@ -0,0 +1,192 @@
{% extends "portal/base.html" %}
{% block title %}Your locations{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{% endblock %}
{% block content %}
<div class="reveal">
<div class="text-[11px] uppercase tracking-[0.2em] text-seismo-orange/80 font-mono mb-2">Live monitoring</div>
<h1 class="text-3xl font-bold tracking-tight">Your locations</h1>
<p class="text-[var(--text-dim)] text-sm mt-1">Real-time sound levels across your active monitoring sites.</p>
</div>
{% if locations %}
<!-- Status rollup (filled live from the per-location /live fetches) -->
<div id="rollup" class="hidden mt-6 mb-6 flex flex-wrap items-center gap-2.5">
<div class="panel px-4 py-2.5 flex items-center gap-2.5">
<span class="text-[var(--text-dim)] text-[10px] uppercase tracking-[0.15em]">Locations</span>
<b id="r-total" class="reading text-lg font-semibold">&ndash;</b>
</div>
<div class="panel px-4 py-2.5 flex items-center gap-2">
<span class="live-dot"></span><b id="r-live" class="reading text-lg font-semibold text-seismo-orange">&ndash;</b><span class="text-[var(--text-dim)] text-xs">live</span>
</div>
<div class="panel px-4 py-2.5 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-[var(--text-dim)]/50"></span><b id="r-off" class="reading text-lg font-semibold">&ndash;</b><span class="text-[var(--text-dim)] text-xs">offline</span>
</div>
<div id="r-peak-wrap" class="hidden panel px-4 py-2.5 flex items-center gap-2">
<span class="text-[var(--text-dim)] text-[10px] uppercase tracking-[0.15em]">Loudest now</span>
<b id="r-peak" class="reading text-lg font-semibold text-seismo-orange">&ndash;</b><span class="text-[var(--text-dim)] text-xs">dB</span>
<span id="r-peak-loc" class="text-[var(--text-dim)] text-sm"></span>
</div>
</div>
<div id="loc-map" class="panel reveal hidden h-72 overflow-hidden mb-6" style="animation-delay:80ms"></div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for loc in locations %}
<a href="/portal/location/{{ loc.id }}" data-loc="{{ loc.id }}"
class="loc-tile panel panel-hover reveal block p-5" style="animation-delay: {{ (loop.index0 * 55) + 140 }}ms">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold tracking-tight truncate">{{ loc.name }}</div>
<div class="text-xs text-[var(--text-dim)] mt-0.5 truncate">{{ loc.address or loc.project_name or '' }}</div>
</div>
<span class="loc-badge hidden shrink-0"></span>
</div>
<div class="mt-5 flex items-baseline gap-1.5">
<span class="loc-leq reading text-[2.6rem] leading-none font-semibold">--</span>
<span class="text-xs text-[var(--text-dim)] font-mono tracking-wide">dB&nbsp;Leq</span>
</div>
<div class="loc-fresh text-[11px] text-[var(--text-dim)]/70 mt-2 font-mono">&nbsp;</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No active monitoring locations yet.</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const LOCATIONS = {{ locations|tojson }};
const liveState = {}; // loc.id -> {status, leq(num|null), leqStr}
const markersById = {}; // loc.id -> circleMarker (for live recolor)
let tiles = null; // map tile layer (re-skinned on theme toggle)
// Dot/level color (computed hex; reads the theme CSS vars so it flips with theme).
const LEVEL_AMBER = 55, LEVEL_RED = 70;
function levelColor(st) {
if (!st || st.status !== 'measuring' || st.leq == null) return cssVar('--text-dim');
if (st.leq >= LEVEL_RED) return cssVar('--lvl-bad');
if (st.leq >= LEVEL_AMBER) return cssVar('--lvl-warn');
return cssVar('--lvl-ok');
}
function tileUrl() {
return document.documentElement.getAttribute('data-theme') === 'light'
? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
}
// Re-skin map tiles + recolor everything when the theme flips.
document.addEventListener('portal-theme', () => { if (tiles) tiles.setUrl(tileUrl()); refreshAll(); });
function num(v) { const f = parseFloat(v); return isNaN(f) ? null : f; }
function fmtAgo(iso) {
if (!iso) return '';
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
if (s < 60) return 'updated just now';
if (s < 3600) return 'updated ' + Math.round(s / 60) + 'm ago';
return 'updated ' + Math.round(s / 3600) + 'h ago';
}
const BADGE_BASE = 'loc-badge inline-flex items-center gap-1.5 shrink-0 px-2.5 py-1 text-[11px] rounded-full border ';
function updateMarker(loc) {
const m = markersById[loc.id]; if (!m) return;
const st = liveState[loc.id];
m.setStyle({ fillColor: levelColor(st) });
let label = `<b>${esc(loc.name)}</b>`;
if (st) {
if (st.status === 'measuring') label += ` &middot; ${esc(st.leqStr)} dB Leq`;
else if (st.status === 'stopped') label += ' &middot; stopped';
else if (st.status === 'nodevice') label += ' &middot; no device';
else label += ' &middot; offline';
}
m.setTooltipContent(label);
}
async function loadTile(loc) {
const el = document.querySelector(`.loc-tile[data-loc="${loc.id}"]`);
const leqEl = el && el.querySelector('.loc-leq'),
badge = el && el.querySelector('.loc-badge'),
fresh = el && el.querySelector('.loc-fresh');
try {
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(loc.id)}/live`)).json();
const d = j.data;
if (!d) {
liveState[loc.id] = { status: j.reason === 'no_device' ? 'nodevice' : 'offline', leq: null };
if (badge) { badge.classList.remove('hidden'); badge.className = BADGE_BASE + 'border-[var(--border)] text-[var(--text-dim)]'; badge.textContent = j.reason === 'no_device' ? 'No device' : 'Offline'; }
if (leqEl) { leqEl.textContent = '--'; leqEl.style.color = 'var(--text-dim)'; }
if (fresh) fresh.innerHTML = '&nbsp;';
} else {
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
const leqStr = (d.leq == null || d.leq === '') ? '--' : d.leq;
liveState[loc.id] = { status: measuring ? 'measuring' : 'stopped', leq: num(d.leq), leqStr };
if (leqEl) { leqEl.textContent = leqStr; leqEl.style.color = measuring ? levelColor(liveState[loc.id]) : 'var(--text)'; }
if (badge) {
badge.classList.remove('hidden');
if (measuring) { badge.className = BADGE_BASE + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; badge.innerHTML = '<span class="live-dot"></span> Live'; }
else { badge.className = BADGE_BASE + 'border-[var(--border)] text-[var(--text-dim)]'; badge.textContent = 'Stopped'; }
}
if (fresh) fresh.textContent = fmtAgo(d.last_seen);
}
} catch (e) { /* leave placeholders */ }
updateMarker(loc);
}
function updateRollup() {
const total = LOCATIONS.length;
let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null;
for (const l of LOCATIONS) {
const s = liveState[l.id]; if (!s) continue;
if (s.status === 'measuring') {
live++;
if (s.leq != null && (peak == null || s.leq > peak)) { peak = s.leq; peakStr = s.leqStr; peakLoc = l.name; }
} else if (s.status === 'offline' || s.status === 'nodevice') off++;
}
document.getElementById('r-total').textContent = total;
document.getElementById('r-live').textContent = live;
document.getElementById('r-off').textContent = off;
const pw = document.getElementById('r-peak-wrap');
if (peak != null) {
pw.classList.remove('hidden');
document.getElementById('r-peak').textContent = peakStr;
document.getElementById('r-peak-loc').textContent = peakLoc;
} else pw.classList.add('hidden');
document.getElementById('rollup').classList.remove('hidden');
}
async function refreshAll() {
await Promise.all(LOCATIONS.map(loadTile));
updateRollup();
}
refreshAll();
setInterval(refreshAll, 15000);
// Map of locations with coordinates — dark tiles, dots recolor live.
const withCoords = LOCATIONS.filter(l => l.coordinates);
if (withCoords.length) {
const mapEl = document.getElementById('loc-map');
mapEl.classList.remove('hidden');
const map = L.map('loc-map', { scrollWheelZoom: false, attributionControl: true });
tiles = L.tileLayer(tileUrl(), {
maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap © CARTO'
}).addTo(map);
const pts = [];
withCoords.forEach(l => {
const [la, lo] = (l.coordinates || '').split(',').map(Number);
if (!isNaN(la) && !isNaN(lo)) {
markersById[l.id] = L.circleMarker([la, lo], {
radius: 7, fillColor: levelColor(liveState[l.id]), color: '#fff',
weight: 2, opacity: 0.9, fillOpacity: 0.95,
}).addTo(map).bindTooltip(esc(l.name), { direction: 'top', offset: [0, -6] });
pts.push([la, lo]);
}
});
if (pts.length) map.fitBounds(pts, { padding: [36, 36], maxZoom: 15 });
else mapEl.classList.add('hidden');
LOCATIONS.forEach(updateMarker);
}
</script>
{% endblock %}
+143 -1
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
<div class="mb-6"> <div class="mb-6 flex items-center justify-between gap-3">
<nav class="flex items-center space-x-2 text-sm"> <nav class="flex items-center space-x-2 text-sm">
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center"> <a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -17,6 +17,28 @@
</svg> </svg>
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span> <span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
</nav> </nav>
<!-- Client portal actions for this project -->
<div class="shrink-0 flex items-center gap-2">
<button type="button" onclick="openShareModal()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
title="Get a shareable link to this project's client portal">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 11-5.656-5.656l1.5-1.5"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 115.656 5.656l-1.5 1.5"></path>
</svg>
Copy client link
</button>
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
title="Preview this project's client portal in a new tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
View client portal
</a>
</div>
</div> </div>
<!-- Header (loads dynamically) --> <!-- Header (loads dynamically) -->
@@ -2074,5 +2096,125 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
</script>
<!-- Share client portal link modal -->
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick="if(event.target===this)closeShareModal()">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
<div class="flex items-center justify-between mb-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal link</h3>
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Anyone with a link can view this project's client portal (read-only). Links are revocable.
</p>
{% if portal_open_links %}
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) -->
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label>
<div class="flex gap-2">
<input id="open-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
</div>
{% endif %}
<div id="share-new" class="hidden mb-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link &mdash; copy it now</label>
<div class="flex gap-2">
<input id="share-new-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyShareUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
</div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Active links</span>
<button onclick="generateShareLink()" class="text-sm text-seismo-orange hover:text-seismo-navy font-medium">+ Generate new link</button>
</div>
<div id="share-list" class="space-y-2 max-h-56 overflow-y-auto"></div>
</div>
</div>
<script>
const SHARE_PROJECT_ID = "{{ project_id }}";
function openShareModal() {
document.getElementById('share-modal').classList.remove('hidden');
document.getElementById('share-new').classList.add('hidden');
const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on
if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
loadShareLinks();
}
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
function copyOpenUrl(btn) {
const inp = document.getElementById('open-url');
inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); }
}
async function loadShareLinks() {
const list = document.getElementById('share-list');
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';
try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-links`)).json();
if (!j.links || !j.links.length) {
list.innerHTML = '<div class="text-sm text-gray-400">No links yet — generate one above.</div>';
return;
}
list.innerHTML = '';
for (const l of j.links) {
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used';
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
row.innerHTML = `<div class="text-sm min-w-0">
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div>
<div class="text-xs text-gray-400">${last}</div></div>`;
const btn = document.createElement('button');
btn.className = 'shrink-0 text-xs text-red-600 hover:text-red-700';
btn.textContent = 'Revoke';
btn.onclick = () => revokeShareLink(l.id);
row.appendChild(btn);
list.appendChild(row);
}
} catch (e) {
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>';
}
}
async function generateShareLink() {
try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link`, { method: 'POST' })).json();
if (j.url) {
document.getElementById('share-new').classList.remove('hidden');
document.getElementById('share-new-url').value = j.url;
loadShareLinks();
}
} catch (e) {
if (window.showToast) showToast('Failed to generate link', 'error');
}
}
function copyShareUrl(btn) {
const inp = document.getElementById('share-new-url');
inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); }
}
async function revokeShareLink(id) {
if (!confirm('Revoke this link? Anyone using it will be signed out on their next action.')) return;
try { await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link/${id}/revoke`, { method: 'POST' }); loadShareLinks(); }
catch (e) { if (window.showToast) showToast('Failed to revoke', 'error'); }
}
</script> </script>
{% endblock %} {% endblock %}
+49
View File
@@ -472,6 +472,20 @@
<button onclick="saveCalibrationDefaults()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"> <button onclick="saveCalibrationDefaults()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Save Defaults Save Defaults
</button> </button>
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Sync from SFM events</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
Reads <code>calibration_date</code> from each seismograph's most recent event sidecar and updates
<em>Last Calibrated</em> when the device reports a newer date than what's stored.
Manual edits made after the latest event are preserved. Runs automatically once a day.
</p>
<button onclick="runCalibrationSync()" id="cal-sync-btn"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
Sync now
</button>
<div id="cal-sync-result" class="mt-3 text-sm text-gray-700 dark:text-gray-300"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -890,6 +904,41 @@ async function saveCalibrationDefaults() {
} }
} }
async function runCalibrationSync() {
const btn = document.getElementById('cal-sync-btn');
const out = document.getElementById('cal-sync-result');
btn.disabled = true;
const originalLabel = btn.textContent;
btn.textContent = 'Syncing…';
out.textContent = '';
out.className = 'mt-3 text-sm text-gray-700 dark:text-gray-300';
try {
const response = await fetch('/api/calibration/sync', { method: 'POST' });
const data = await response.json();
if (!response.ok) {
out.className = 'mt-3 text-sm text-red-600 dark:text-red-400';
out.textContent = 'Error: ' + (data.detail || response.statusText);
return;
}
const parts = [
`Checked ${data.checked}`,
`Updated ${data.updated}`,
`Already in sync ${data.already_in_sync}`,
`Manual kept ${data.skipped_manual_newer}`,
`No event ${data.no_event}`,
];
if (data.errors) parts.push(`Errors ${data.errors}`);
out.textContent = parts.join(' · ');
} catch (error) {
out.className = 'mt-3 text-sm text-red-600 dark:text-red-400';
out.textContent = 'Error: ' + error.message;
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
// ========== DATA TAB - IMPORT/EXPORT ========== // ========== DATA TAB - IMPORT/EXPORT ==========
// Merge Mode Import // Merge Mode Import
+263
View File
@@ -112,4 +112,267 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Alerts -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">Alerts
<span id="alert-state-badge" class="hidden text-xs px-2 py-0.5 rounded-full"></span>
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">Threshold rules evaluated on this device's live feed. An enabled alert keeps the device monitored 24/7.</p>
</div>
<button onclick="openAlertForm()" type="button"
class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">+ Add alert</button>
</div>
<div id="alert-rules-list" class="space-y-2"></div>
<!-- create / edit form -->
<div id="alert-form" class="hidden mt-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900/40">
<input type="hidden" id="ar-id">
<div class="grid sm:grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Name</label>
<input id="ar-name" type="text" placeholder="e.g. Night noise limit"
class="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-gray-800 dark:text-gray-200">
</div>
<label class="flex items-end gap-2 text-sm text-gray-700 dark:text-gray-300 pb-1">
<input type="checkbox" id="ar-enabled" checked class="rounded"> Enabled
</label>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>Alert when</span>
<select id="ar-metric" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<option value="leq">Leq</option><option value="lp">Lp</option>
<option value="lmax">Lmax</option><option value="lpeak">Lpeak</option>
<option value="ln1">L1</option><option value="ln2">L10</option>
</select>
<span>is</span>
<select id="ar-comparison" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<option value="above">above</option><option value="below">below</option>
</select>
<input id="ar-threshold" type="number" step="0.1" placeholder="65"
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>dB</span>
<span>for</span>
<input id="ar-duration" type="number" min="0" value="0"
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>seconds</span>
</div>
<div class="mt-3">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" id="ar-sched-on" onchange="toggleSchedule()" class="rounded"> Only during certain hours
</label>
<div id="ar-sched" class="hidden mt-2 flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>from</span><input id="ar-start" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<span>to</span><input id="ar-end" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
<span class="ml-2">on</span>
<span id="ar-days" class="flex gap-1"></span>
</div>
</div>
<details class="mt-3 text-sm text-gray-600 dark:text-gray-400">
<summary class="cursor-pointer select-none">Advanced</summary>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span>Clear margin</span><input id="ar-margin" type="number" step="0.1" value="2"
class="w-16 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>dB (hysteresis)</span>
<span>Cooldown</span><input id="ar-cooldown" type="number" min="0" value="300"
class="w-20 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>s</span>
</div>
</details>
<div class="mt-4 flex gap-2">
<button onclick="saveAlertRule()" type="button" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Save</button>
<button onclick="closeAlertForm()" type="button" class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700">Cancel</button>
</div>
</div>
<!-- Alert history -->
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">History</h3>
<button onclick="loadAlertEvents()" type="button" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Refresh</button>
</div>
<div id="alert-events" class="space-y-2"></div>
</div>
</div>
<script>
const ALERT_UNIT = "{{ unit_id }}";
const METRIC_LABELS = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Mon=0 .. Sun=6
// Render the day checkboxes once.
(function () {
const wrap = document.getElementById('ar-days');
DAY_LABELS.forEach((lbl, i) => {
const l = document.createElement('label');
l.className = 'inline-flex items-center gap-0.5';
l.innerHTML = `<input type="checkbox" id="ar-day-${i}" class="rounded"><span class="ml-0.5">${lbl}</span>`;
wrap.appendChild(l);
});
})();
function condText(r) {
const m = METRIC_LABELS[r.metric] || r.metric;
let s = `${m} ${r.comparison} ${r.threshold_db} dB`;
if (r.duration_s) s += ` for ${r.duration_s}s`;
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}${r.schedule_end}`;
return s;
}
function renderRule(r) {
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
row.innerHTML = `<div class="min-w-0">
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${r.name}${r.enabled ? '' : ' <span class="text-xs text-gray-400">(disabled)</span>'}</div>
<div class="text-xs text-gray-500">${condText(r)}</div></div>
<div class="shrink-0 flex items-center gap-3 text-xs">
<button data-act="edit" class="text-seismo-orange hover:underline">Edit</button>
<button data-act="del" class="text-red-600 hover:underline">Delete</button>
</div>`;
row.querySelector('[data-act="edit"]').onclick = () => openAlertForm(r);
row.querySelector('[data-act="del"]').onclick = () => deleteAlertRule(r.id);
return row;
}
async function loadAlertRules() {
const list = document.getElementById('alert-rules-list');
try {
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules`)).json();
const rules = j.rules || [];
if (!rules.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts configured.</div>'; return; }
list.innerHTML = '';
rules.forEach(r => list.appendChild(renderRule(r)));
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load alerts.</div>'; }
}
function toggleSchedule() {
document.getElementById('ar-sched').classList.toggle('hidden', !document.getElementById('ar-sched-on').checked);
}
function openAlertForm(r) {
document.getElementById('alert-form').classList.remove('hidden');
document.getElementById('ar-id').value = r ? r.id : '';
document.getElementById('ar-name').value = r ? r.name : '';
document.getElementById('ar-metric').value = r ? r.metric : 'leq';
document.getElementById('ar-comparison').value = r ? r.comparison : 'above';
document.getElementById('ar-threshold').value = (r && r.threshold_db != null) ? r.threshold_db : '';
document.getElementById('ar-duration').value = r ? r.duration_s : 0;
document.getElementById('ar-enabled').checked = r ? r.enabled : true;
document.getElementById('ar-margin').value = r ? r.clear_margin_db : 2;
document.getElementById('ar-cooldown').value = r ? r.cooldown_s : 300;
const hasSched = !!(r && r.schedule_start && r.schedule_end);
document.getElementById('ar-sched-on').checked = hasSched;
document.getElementById('ar-start').value = hasSched ? r.schedule_start : '';
document.getElementById('ar-end').value = hasSched ? r.schedule_end : '';
const days = (r && r.schedule_days) ? r.schedule_days.split(',') : [];
DAY_LABELS.forEach((_, i) => { document.getElementById('ar-day-' + i).checked = days.includes(String(i)); });
toggleSchedule();
}
function closeAlertForm() { document.getElementById('alert-form').classList.add('hidden'); }
async function saveAlertRule() {
const id = document.getElementById('ar-id').value;
const threshold = parseFloat(document.getElementById('ar-threshold').value);
if (isNaN(threshold)) { if (window.showToast) showToast('Enter a threshold', 'error'); return; }
const schedOn = document.getElementById('ar-sched-on').checked;
const days = DAY_LABELS.map((_, i) => document.getElementById('ar-day-' + i).checked ? i : null).filter(v => v !== null);
const payload = {
name: document.getElementById('ar-name').value || 'Alert',
metric: document.getElementById('ar-metric').value,
comparison: document.getElementById('ar-comparison').value,
threshold_db: threshold,
duration_s: parseInt(document.getElementById('ar-duration').value) || 0,
clear_margin_db: parseFloat(document.getElementById('ar-margin').value) || 2,
cooldown_s: parseInt(document.getElementById('ar-cooldown').value) || 300,
schedule_start: schedOn ? (document.getElementById('ar-start').value || null) : null,
schedule_end: schedOn ? (document.getElementById('ar-end').value || null) : null,
schedule_days: (schedOn && days.length) ? days.join(',') : null,
enabled: document.getElementById('ar-enabled').checked,
};
const url = id ? `/api/slmm/${ALERT_UNIT}/alerts/rules/${id}` : `/api/slmm/${ALERT_UNIT}/alerts/rules`;
try {
const r = await fetch(url, { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!r.ok) throw new Error('save failed');
closeAlertForm(); loadAlertRules();
if (window.showToast) showToast('Alert saved', 'success');
} catch (e) { if (window.showToast) showToast('Failed to save alert', 'error'); }
}
async function deleteAlertRule(id) {
if (!confirm('Delete this alert rule?')) return;
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules/${id}`, { method: 'DELETE' }); loadAlertRules(); }
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
}
// ---- alert history (events) ----------------------------------------------
function fmtAlertTime(iso) {
if (!iso) return '';
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString();
}
function updateAlertState(events) {
const badge = document.getElementById('alert-state-badge');
badge.classList.remove('hidden');
const active = events.filter(e => e.status === 'active').length;
if (active) {
badge.textContent = `● ${active} active`;
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
} else {
badge.textContent = '✓ All clear';
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
}
function renderEvent(e) {
const m = METRIC_LABELS[e.metric] || e.metric;
const active = e.status === 'active';
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border ' +
(active ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
: 'border-slate-200 dark:border-slate-700');
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
const ack = e.acknowledged_at ? ` · ack'd${e.acknowledged_by ? ' by ' + e.acknowledged_by : ''}` : '';
row.innerHTML = `<div class="min-w-0">
<div class="text-sm truncate">
<span class="${active ? 'text-red-600 dark:text-red-400 font-medium' : 'text-gray-800 dark:text-gray-200'}">${e.rule_name || 'Alert'}</span>
<span class="text-xs text-gray-500"> · ${m} ${e.threshold_db} dB</span>
</div>
<div class="text-xs text-gray-500">${when}${peak}${ack}</div></div>`;
if (!e.acknowledged_at) {
const btn = document.createElement('button');
btn.className = 'shrink-0 text-xs text-seismo-orange hover:underline';
btn.textContent = 'Ack';
btn.onclick = () => ackEvent(e.id);
row.appendChild(btn);
}
return row;
}
async function loadAlertEvents() {
const list = document.getElementById('alert-events');
try {
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events?limit=50`)).json();
const events = j.events || [];
updateAlertState(events);
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts have fired.</div>'; return; }
list.innerHTML = '';
events.forEach(e => list.appendChild(renderEvent(e)));
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load history.</div>'; }
}
async function ackEvent(id) {
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events/${id}/ack`, { method: 'POST' }); loadAlertEvents(); }
catch (e) { if (window.showToast) showToast('Failed to acknowledge', 'error'); }
}
loadAlertRules();
loadAlertEvents();
setInterval(loadAlertEvents, 20000); // surface new breaches / clears
</script>
{% endblock %} {% endblock %}
+276 -35
View File
@@ -51,14 +51,32 @@
<!-- Live Measurement Chart - shows when a device is selected --> <!-- Live Measurement Chart - shows when a device is selected -->
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8"> <div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-start justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2> <div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Live Measurements
<span id="panel-unit-id" class="text-seismo-orange"></span>
</h2>
<!-- Measuring state + cache freshness (populated from cached /status, no device hit) -->
<div class="mt-1 flex items-center gap-2 text-sm">
<span id="panel-measuring-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
<span id="panel-freshness" class="text-gray-500 dark:text-gray-400"></span>
</div>
</div>
<div class="flex items-center gap-3">
<button onclick="refreshDashboardPanel()" title="Refresh from device"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange">
<svg id="panel-refresh-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> <button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
</button> </button>
</div> </div>
</div>
<!-- Current Metrics --> <!-- Current Metrics -->
<div class="grid grid-cols-5 gap-4 mb-6"> <div class="grid grid-cols-5 gap-4 mb-6">
@@ -81,14 +99,14 @@
</div> </div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4"> <div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p> <p id="chart-ln1-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L1</p>
<p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p> <p id="chart-ln1" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4"> <div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p> <p id="chart-ln2-label" class="text-xs text-gray-600 dark:text-gray-400 mb-1">L10</p>
<p id="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p> <p id="chart-ln2" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p> <p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
</div> </div>
</div> </div>
@@ -150,9 +168,18 @@ window.selectedUnitId = null;
window.dashboardChartData = { window.dashboardChartData = {
timestamps: [], timestamps: [],
lp: [], lp: [],
leq: [] leq: [],
ln1: [],
ln2: []
}; };
// Parse a metric to a number, or null (so a missing/"-.-" percentile leaves a gap
// in the line instead of dropping it to 0).
function numOrNull(v) {
const f = parseFloat(v);
return isNaN(f) ? null : f;
}
// Initialize Chart.js // Initialize Chart.js
function initializeDashboardChart() { function initializeDashboardChart() {
if (typeof Chart === 'undefined') { if (typeof Chart === 'undefined') {
@@ -194,6 +221,26 @@ function initializeDashboardChart() {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0 pointRadius: 0
},
{
label: 'L1',
data: [],
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
spanGaps: true
},
{
label: 'L10',
data: [],
borderColor: 'rgb(249, 115, 22)',
backgroundColor: 'rgba(249, 115, 22, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
spanGaps: true
} }
] ]
}, },
@@ -244,12 +291,24 @@ function showLiveChart(unitId) {
initializeDashboardChart(); initializeDashboardChart();
} }
// Reset data // Reset data for the newly-selected unit (clears any prior unit's line)
window.dashboardChartData = { window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] };
timestamps: [], if (window.dashboardChart) {
lp: [], window.dashboardChart.data.labels = [];
leq: [] window.dashboardChart.data.datasets.forEach(ds => ds.data = []);
}; window.dashboardChart.update('none');
}
// Name the unit; clear stale status until the cache read returns
const unitLabel = document.getElementById('panel-unit-id');
if (unitLabel) unitLabel.textContent = '· ' + unitId;
setPanelStatus(null, null);
// Populate immediately from CACHE (no device hit): KPI cards + chart trail.
prefillDashboardPanel(unitId);
backfillDashboardChart(unitId);
// Keep the cards updating from cache (~15s) without opening a device stream.
startPanelCachePolling(unitId);
// Scroll to chart // Scroll to chart
panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -257,6 +316,7 @@ function showLiveChart(unitId) {
function closeLiveChart() { function closeLiveChart() {
stopDashboardStream(); stopDashboardStream();
stopPanelCachePolling();
document.getElementById('live-chart-panel').classList.add('hidden'); document.getElementById('live-chart-panel').classList.add('hidden');
window.selectedUnitId = null; window.selectedUnitId = null;
} }
@@ -270,17 +330,12 @@ function startDashboardStream() {
window.dashboardWebSocket.close(); window.dashboardWebSocket.close();
} }
// Reset chart data // The live WS takes over from the cache poller; keep the backfilled trail on
window.dashboardChartData = { timestamps: [], lp: [], leq: [] }; // the chart so the live frames continue the line instead of blanking it.
if (window.dashboardChart) { stopPanelCachePolling();
window.dashboardChart.data.labels = [];
window.dashboardChart.data.datasets[0].data = [];
window.dashboardChart.data.datasets[1].data = [];
window.dashboardChart.update();
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`; const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`;
window.dashboardWebSocket = new WebSocket(wsUrl); window.dashboardWebSocket = new WebSocket(wsUrl);
@@ -293,6 +348,10 @@ function startDashboardStream() {
window.dashboardWebSocket.onmessage = function(event) { window.dashboardWebSocket.onmessage = function(event) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// /monitor sends keepalive 'heartbeat' frames (no metrics) and a per-frame
// 'feed_status'; skip heartbeats and offline frames so they don't blank the
// metrics or spike the chart with zeros.
if (data.heartbeat || data.feed_status === 'unreachable') return;
updateDashboardMetrics(data); updateDashboardMetrics(data);
updateDashboardChart(data); updateDashboardChart(data);
} catch (error) { } catch (error) {
@@ -316,37 +375,219 @@ function stopDashboardStream() {
window.dashboardWebSocket.close(); window.dashboardWebSocket.close();
window.dashboardWebSocket = null; window.dashboardWebSocket = null;
} }
// Fall back to cache polling so the cards keep refreshing while the panel is open.
if (window.selectedUnitId && !document.getElementById('live-chart-panel').classList.contains('hidden')) {
startPanelCachePolling(window.selectedUnitId);
}
} }
function updateDashboardMetrics(data) { function updateDashboardMetrics(data) {
document.getElementById('chart-lp').textContent = data.lp || '--'; document.getElementById('chart-lp').textContent = data.lp || '--';
document.getElementById('chart-leq').textContent = data.leq || '--'; document.getElementById('chart-leq').textContent = data.leq || '--';
document.getElementById('chart-lmax').textContent = data.lmax || '--'; document.getElementById('chart-lmax').textContent = data.lmax || '--';
document.getElementById('chart-lmin').textContent = data.lmin || '--'; // Guard: DRD stream frames omit percentiles, so only overwrite when present
document.getElementById('chart-lpeak').textContent = data.lpeak || '--'; // (else the live stream blanks L1/L10 over the cached DOD snapshot values).
if (data.ln1 != null) document.getElementById('chart-ln1').textContent = data.ln1;
if (data.ln2 != null) document.getElementById('chart-ln2').textContent = data.ln2;
if (data.ln1_label) document.getElementById('chart-ln1-label').textContent = data.ln1_label;
if (data.ln2_label) document.getElementById('chart-ln2-label').textContent = data.ln2_label;
} }
function updateDashboardChart(data) { function updateDashboardChart(data) {
const cd = window.dashboardChartData;
const now = new Date(); const now = new Date();
window.dashboardChartData.timestamps.push(now.toLocaleTimeString()); cd.timestamps.push(now.toLocaleTimeString());
window.dashboardChartData.lp.push(parseFloat(data.lp || 0)); cd.lp.push(numOrNull(data.lp));
window.dashboardChartData.leq.push(parseFloat(data.leq || 0)); cd.leq.push(numOrNull(data.leq));
// /monitor (DOD) frames carry ln1/ln2; a DRD frame would omit them -> null gap.
cd.ln1.push(numOrNull(data.ln1));
cd.ln2.push(numOrNull(data.ln2));
// Keep only last 60 data points // Keep a generous window (backfill seeds up to ~120 points from the 2h trail).
if (window.dashboardChartData.timestamps.length > 60) { if (cd.timestamps.length > 600) {
window.dashboardChartData.timestamps.shift(); cd.timestamps.shift();
window.dashboardChartData.lp.shift(); cd.lp.shift();
window.dashboardChartData.leq.shift(); cd.leq.shift();
cd.ln1.shift();
cd.ln2.shift();
} }
if (window.dashboardChart) { if (window.dashboardChart) {
window.dashboardChart.data.labels = window.dashboardChartData.timestamps; window.dashboardChart.data.labels = cd.timestamps;
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp; window.dashboardChart.data.datasets[0].data = cd.lp;
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq; window.dashboardChart.data.datasets[1].data = cd.leq;
window.dashboardChart.data.datasets[2].data = cd.ln1;
window.dashboardChart.data.datasets[3].data = cd.ln2;
window.dashboardChart.update('none'); window.dashboardChart.update('none');
} }
} }
// ---- Cached-data panel population (no device hit) -----------------------
// Fill the KPI cards + measuring/freshness from the cached NL43Status snapshot.
async function prefillDashboardPanel(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/status`);
if (!r.ok) { // 404 = device has never reported yet
setPanelStatus(null, null);
return;
}
const d = (await r.json()).data || {};
updateDashboardMetrics(d); // lp/leq/lmax/ln1/ln2 (ln guards keep cached percentiles)
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setPanelStatus(measuring, d.last_seen);
} catch (e) {
console.warn('Panel cache prefill failed:', e);
}
}
// Seed the chart from the downsampled DOD trail so it shows recent trend on open.
async function backfillDashboardChart(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
if (!r.ok) return;
const readings = (await r.json()).readings || [];
const cd = window.dashboardChartData;
if (!cd) return;
for (const row of readings) {
// Trail timestamps are naive UTC; append 'Z' to render in local time
// consistently with the live frames (which use local Date.now()).
cd.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
cd.lp.push(numOrNull(row.lp));
cd.leq.push(numOrNull(row.leq));
cd.ln1.push(numOrNull(row.ln1));
cd.ln2.push(numOrNull(row.ln2));
}
if (window.dashboardChart) {
window.dashboardChart.data.labels = cd.timestamps;
window.dashboardChart.data.datasets[0].data = cd.lp;
window.dashboardChart.data.datasets[1].data = cd.leq;
window.dashboardChart.data.datasets[2].data = cd.ln1;
window.dashboardChart.data.datasets[3].data = cd.ln2;
window.dashboardChart.update('none');
}
} catch (e) {
console.warn('Panel chart backfill failed:', e);
}
}
// Measuring badge + "as of <time> (Xm ago)" freshness, so a cached value is never
// mistaken for a live one. measuring: true | false | null(unknown).
function setPanelStatus(measuring, lastSeenIso) {
const badge = document.getElementById('panel-measuring-badge');
const fresh = document.getElementById('panel-freshness');
if (badge) {
if (measuring === null) {
badge.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full';
badge.textContent = '';
} else if (measuring) {
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
badge.textContent = '● Measuring';
} else {
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
badge.textContent = '■ Stopped';
}
}
if (fresh) fresh.innerHTML = fmtFreshness(lastSeenIso);
}
// Human "x ago" with a staleness hint. Cached timestamps are naive UTC.
function fmtFreshness(lastSeenIso) {
if (!lastSeenIso) return '<span class="text-gray-400">no cached reading yet</span>';
const t = new Date(lastSeenIso.endsWith('Z') ? lastSeenIso : lastSeenIso + 'Z');
const secs = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
let ago, stale = false;
if (secs < 10) ago = 'just now';
else if (secs < 60) ago = secs + 's ago';
else if (secs < 3600) { ago = Math.round(secs / 60) + 'm ago'; stale = secs >= 300; }
else { ago = Math.round(secs / 3600) + 'h ago'; stale = true; }
const cls = stale ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400';
const tag = stale ? ' · cached' : '';
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${tag})</span>`;
}
// Cache polling: refresh the cards from cache every 15s while the panel is open
// and not live-streaming. Pure cache reads — no device contention.
function startPanelCachePolling(unitId) {
stopPanelCachePolling();
window.panelCacheTimer = setInterval(() => {
if (window.selectedUnitId) prefillDashboardPanel(window.selectedUnitId);
}, 15000);
}
function stopPanelCachePolling() {
if (window.panelCacheTimer) { clearInterval(window.panelCacheTimer); window.panelCacheTimer = null; }
}
// ---- On-demand device refresh (the per-unit + panel refresh buttons) -----
// One bounded, user-initiated device read: hits the device, updates the cache,
// returns the fresh data. Throws on unreachable/disabled.
async function forceDeviceRead(unitId) {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/live`);
if (!r.ok) {
let detail = 'device unreachable';
try { detail = (await r.json()).detail || detail; } catch (e) {}
throw new Error(detail);
}
return (await r.json()).data || {};
}
function spinIcon(el, on) {
if (el) el.classList.toggle('animate-spin', on);
}
function applyFreshReadToPanel(unitId, d) {
if (window.selectedUnitId !== unitId) return;
updateDashboardMetrics(d);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
// The read just happened, so "now" is the accurate freshness even if the
// /live payload doesn't echo last_seen.
setPanelStatus(measuring, d.last_seen || new Date().toISOString());
}
// Device-list row refresh button.
async function refreshSlmUnit(unitId, btn) {
const icon = btn ? btn.querySelector('svg') : null;
if (btn) btn.disabled = true;
spinIcon(icon, true);
try {
const d = await forceDeviceRead(unitId);
applyFreshReadToPanel(unitId, d);
// Reload the list so the row's badge + last-check reflect the new cache.
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
htmx.trigger('#slm-devices-list', 'load');
}
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
} catch (e) {
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
else console.warn('refresh failed', e);
} finally {
if (btn) btn.disabled = false;
spinIcon(icon, false);
}
}
// Panel header refresh button (refreshes the unit the panel is showing).
async function refreshDashboardPanel() {
const unitId = window.selectedUnitId;
if (!unitId) return;
const icon = document.getElementById('panel-refresh-icon');
spinIcon(icon, true);
try {
const d = await forceDeviceRead(unitId);
applyFreshReadToPanel(unitId, d);
updateDashboardChart(d); // append the fresh point to the chart
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
htmx.trigger('#slm-devices-list', 'load');
}
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
} catch (e) {
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
} finally {
spinIcon(icon, false);
}
}
// Configuration modal - use unified SLM settings modal // Configuration modal - use unified SLM settings modal
function openDeviceConfigModal(unitId) { function openDeviceConfigModal(unitId) {
// Call the unified modal function from slm_settings_modal.html // Call the unified modal function from slm_settings_modal.html
+47 -35
View File
@@ -129,6 +129,15 @@
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span> <span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
</p> </p>
</div> </div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
<p id="viewLocationContainer" class="mt-1">
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
<span id="viewLocationText">--</span>
</a>
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
</p>
</div>
<div> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p> <p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
@@ -639,18 +648,12 @@
{% include "partials/project_picker.html" with context %} {% include "partials/project_picker.html" with context %}
</div> </div>
<!-- Address --> <!-- Address / coordinates are managed on the project's
<div> MonitoringLocation, not the unit itself. Edit them on
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label> the project page. -->
<input type="text" name="address" id="address" placeholder="123 Main St, City, State" <div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> Address &amp; coordinates are set on the deployment location.
</div> Open the project to edit them.
<!-- Coordinates -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
</div> </div>
</div> </div>
@@ -848,16 +851,6 @@
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded"> class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span> <span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
</label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true" <input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded"> class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
@@ -1168,8 +1161,28 @@ function populateViewMode() {
if (projectLink) projectLink.classList.add('hidden'); if (projectLink) projectLink.classList.add('hidden');
} }
document.getElementById('viewAddress').textContent = currentUnit.address || '--'; // Deployment Location — comes from the active UnitAssignment →
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--'; // MonitoringLocation. Show project link if present, otherwise
// "Not deployed" placeholder.
const locLink = document.getElementById('viewLocationLink');
const locText = document.getElementById('viewLocationText');
const locNoLink = document.getElementById('viewLocationNoLink');
const activeLoc = currentUnit.active_location;
if (activeLoc && activeLoc.location_id) {
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
if (locLink) {
locLink.href = `/projects/${activeLoc.project_id}`;
locLink.classList.remove('hidden');
}
if (locNoLink) locNoLink.classList.add('hidden');
} else {
if (locLink) locLink.classList.add('hidden');
if (locNoLink) locNoLink.classList.remove('hidden');
}
// Address / coordinates also come from the active assignment.
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
// Seismograph fields // Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--'; document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
@@ -1327,8 +1340,6 @@ function populateEditForm() {
if (projectPickerClear) projectPickerClear.classList.add('hidden'); if (projectPickerClear) projectPickerClear.classList.add('hidden');
} }
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed; document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false; document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
document.getElementById('retired').value = currentUnit.retired ? 'true' : ''; document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
@@ -1609,8 +1620,13 @@ function initUnitMap() {
// Update marker (can be called multiple times) // Update marker (can be called multiple times)
updateMapMarker(lat, lon); updateMapMarker(lat, lon);
// Update location text // Update location text — prefer the assignment's location name, fall
// back to address, then coordinates.
const locationParts = []; const locationParts = [];
const loc = currentUnit.active_location;
if (loc && loc.name) {
locationParts.push(loc.name);
}
if (currentUnit.address) { if (currentUnit.address) {
locationParts.push(currentUnit.address); locationParts.push(currentUnit.address);
} }
@@ -1724,13 +1740,12 @@ async function uploadPhoto(file) {
const result = await response.json(); const result = await response.json();
// Show success message with metadata info // Show success message with metadata info. Location is on the
// assignment's MonitoringLocation now, so we just surface what GPS
// came in — the backend no longer mutates the unit row.
let message = 'Photo uploaded successfully!'; let message = 'Photo uploaded successfully!';
if (result.metadata && result.metadata.coordinates) { if (result.metadata && result.metadata.coordinates) {
message += ` GPS location detected: ${result.metadata.coordinates}`; message += ` GPS location detected: ${result.metadata.coordinates}`;
if (result.coordinates_updated) {
message += ' (Unit coordinates updated automatically)';
}
} else { } else {
message += ' No GPS data found in photo.'; message += ' No GPS data found in photo.';
} }
@@ -1738,11 +1753,8 @@ async function uploadPhoto(file) {
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'; statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
statusDiv.textContent = message; statusDiv.textContent = message;
// Reload photos and unit data // Reload photos
await loadPhotos(); await loadPhotos();
if (result.coordinates_updated) {
await loadUnitData();
}
// Hide status after 5 seconds // Hide status after 5 seconds
setTimeout(() => { setTimeout(() => {