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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
/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>
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>
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
v0.13.0 shipped the mic_unit_pref default as "dBL", which made the
website chart's mic axis inconsistent with the PDF report (which
renders psi). Original brief was always "psi on charts, dBL on
peaks" — I implemented the default backwards. Operator caught it
within an hour of rollout.
Same-day patch:
- backend/models.py: default "dBL" → "psi"
- migrate_add_mic_unit_pref.py: idempotent across both fresh DB
("add column with psi default") and v0.13.0 upgrade ("flip dBL
rows to psi"). One-row table, freshness assumed.
- backend/routers/settings.py: GET/PUT fallback "dBL" → "psi"
- templates/settings.html: dropdown's `selected` flag moves to psi
+ reorders options + relabels with "(matches PDF report)" hint
- backend/static/event-modal.js: module-level fallback + branch
conditions flip to make psi the unset/error default
Includes the "Captured at" → "Time received" relabel from earlier
in the day (already-shipped commit 43c804d) rolled into the
release notes.
Migration is idempotent + safe to re-run; rolled out on the dev
container during this commit's smoke test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Captured at" was easily misread as "when the device captured the
event" — but that's the event's Timestamp at the top of the modal
(unit-local trigger time). source.captured_at is actually when SFM
received and stored the event. New label avoids the ambiguity, and
the hover tooltip spells it out for anyone unsure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 closes the read-only gap between Terra-View and the
standalone SFM webapp on port 8200. Operators no longer need to
bounce between the two for routine event review.
Wraps up four commits shipped this iteration:
db8d666 settings: add mic_unit_pref for event-report chart
1d9fd00 event-modal: port 4-channel Chart.js waveform/histogram
panels + docker-compose mount fix for SFM container
4b2bb9a event-modal: inline PDF preview + .TXT link + review form
2905a32 admin_events: wire shared event-detail modal into the page
Highlights:
- Inline PDF preview via iframe (lazy-loaded; browser-native zoom)
- Chart.js 4-channel waveform/histogram in the modal
- Review form persisted to sidecar via PATCH
- /admin/events row click opens the modal (was port-8200-only)
- mic_unit_pref setting (dBL default, psi alternate; chart only)
- Cross-modal CustomEvent so host tables refresh on save
Phase 2 (device control: start/stop monitoring, push compliance,
erase) deferred pending SFM auth layer — see seismo-relay roadmap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/admin/events previously rendered events as a flat table with no
detail view — admins had to copy an event ID and open the standalone
SFM webapp on port 8200 to see the chart, PDF, or sidecar metadata.
Adds:
- {% include 'partials/event_detail_modal.html' %} + script tag at
the bottom of the page (mirrors the pattern in /sfm, /unit/{id},
/projects/.../nrl/...).
- onclick on the table <tr> opens the modal via showEventDetail(id).
- event.stopPropagation() on the checkbox <td> so selection clicks
don't also open the modal.
- Listener for the 'sfm-event-review-saved' CustomEvent fired by
event-modal.js — reloads the table so any FT-flag changes made in
the modal's review form land on the row without a full reload.
Also propagates the same listener pattern to the three other pages
that already include the modal (sfm.html, unit_detail.html,
vibration_location_detail.html) — they call their respective
loadEvents / loadUnitEvents / loadLocationEvents on the fire. Keeps
the refresh-on-save UX consistent across every page that hosts the
modal.
Phase 1 of the SFM-into-Terra-View integration is now complete:
chart, PDF preview, .TXT download, review form, and per-unit + admin
event browsing are all native in Terra-View. The standalone SFM
webapp on port 8200 remains as a diagnostic fallback but operators
no longer need to bounce to it for routine workflows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions to the shared event-detail modal, closing the gap
versus the standalone SFM webapp:
(1) "Show Event Report PDF" button toggles an inline iframe inside
the modal (no second-layer modal, no new tab). Lazy-loaded — src
isn't set until first reveal, so closing the modal without opening
the PDF never spends bandwidth. Sibling "Download PDF" link for
direct save. Iframe sized to 80vh / min 600px so the typical
letter-portrait single-page report fits with browser-native zoom
controls available.
(2) "Original .TXT report" download link, rendered only when
sidecar.source.txt_filename is present (post-2026-05-27 ingest
events). Hidden for legacy events to avoid 404 dead links.
(3) Inline Review form — false_trigger checkbox + reviewer text
input + notes textarea + Save button. PATCH /api/sfm/db/events/{id}/sidecar
with {"review": {...}}. On save, fires a CustomEvent
'sfm-event-review-saved' on window so table-owning pages
(/sfm, /unit/{id}, /admin/events, /projects/{p}/nrl/{l}) can
listen and refresh their FT badges without reload. Status line
shows the last-reviewed timestamp + Save success/failure feedback.
Smoke-tested end-to-end against a real BE12599 histogram event:
PATCH round-trip lands in the sidecar, GET reflects the change,
no 500s on /report.pdf or /sidecar paths through the proxy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds inline waveform plots to the shared event-detail modal, ported
from sfm/sfm_webapp.html:2555-2880. The standalone SFM webapp's
plot logic moves into event-modal.js with Tailwind-friendly grid +
tick colors (theme-aware via the `dark` class on <html>).
Channels render in BW Event Report order — MicL on top, Tran on
bottom. Mic channel auto-converts psi → dB(L) when the operator's
mic_unit_pref is "dBL" (the default), using _psiToDblForChart with
a MIC_DBL_FLOOR=60 floor so the chart shows an SPL-vs-time curve
instead of a sparse pattern of "moments above floor".
Histograms render as bars with HH:MM:SS x-axis labels when the
sidecar carries time_axis.interval_times (events ingested with the
v0.20 parser); falls back to interval index for older events.
Geo + mic histogram channels enforce minimum Y ranges (0.05 in/s
and 0.001 psi respectively) so quiet events don't fill the panel.
Waveform events get the trigger-line + zero-baseline overlay; the
histogram branch suppresses it (no trigger concept). Downsampling
kicks in at >3000 samples to keep render time bounded.
Modal partial widened max-w-3xl → max-w-5xl to fit the chart panels
without horizontal clipping. Chart.js 4.4.1 loaded from cdn.jsdelivr
at the bottom of the partial, matching the standalone webapp's
reference version pin.
Side-yard: docker-compose bind-mounts ../seismo-relay-prod-snap into
the SFM container so the symlinked DB + waveform store inside
bridges/captures resolve. Without it SFM 500s on every /db/* call
because the symlink target was outside the container's filesystem
view. Read-write (not :ro) because SFM opens the DB in WAL mode
which requires creating -wal and -shm sidecar files even for reads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New UserPreferences field controls the mic channel's unit on the
SFM event-detail modal's waveform chart only. "dBL" default,
"psi" alternate. Peaks everywhere else (tables, KPI tiles, modal
summary) stay in dBL regardless — this is strictly a chart-axis
preference.
Surfaced as a single dropdown on Settings → General, below the
auto-refresh interval.
Setting up the storage half ahead of the chart port in the next
commit, so the chart can read the value from /api/settings/preferences
on first render instead of needing a follow-up wiring pass.
Includes idempotent backend/migrate_add_mic_unit_pref.py for fleets
already on an older schema.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default display timezone for server logs + PDF report rendering on
both terra-view and sfm services. Override per-deployment in this
file for non-US-East installations.
DB columns are always UTC regardless — only affects what operators
see in logs / PDFs / any text-rendered timestamp. Modal display
uses browser TZ via toLocaleString (no server config needed).
Pairs with seismo-relay commit 6381dcb (tz env var support in the
Dockerfile + report_pdf UTC→local conversion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG entry for the five commits that landed after the v0.12.0 tag:
two features (Unit Swap wizard at /tools/unit-swap, editable deployment
timeline on /unit/{id}) and two correctness fixes (RosterUnit.deployed
now flips on swap/unassign/promote; deployment timeline now respects
user timezone for both display and edits). No schema migrations.
README bumped to v0.12.1 with new bullets for the post-v0.12.0 features
and several already-shipped items that were missing from the list (SFM
Event DB Manager, Deployment-History calendar + Gantt tabs, reusable
location-map partial). backend/main.py VERSION constant bumped too.
Deployment timestamps were stored correctly as UTC but rendered raw —
a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the
naive UTC ISO string straight to the screen.
Display side: deployment_timeline.py now converts every emitted
timestamp (starts_at, ends_at, event_overlay.peak_pvs_at and last_event)
through `utc_to_local()` using the user's configured timezone from
UserPreferences before serializing. Frontend slice keeps working — it
just slices a local-time string now.
Write side (so the new edit / add-historical-assignment modals stay
consistent):
- PATCH /api/projects/{pid}/assignments/{aid}
- POST /api/projects/{pid}/locations/{loc}/assign
both now interpret a *naive* assigned_at / assigned_until ISO string as
the user's local time and convert to UTC for storage via
`local_to_utc()`. Explicit tz-aware strings ("...Z" or "...+00:00")
skip the conversion so programmatic callers that already speak UTC
keep working.
Verified live: BE13121's stored 2026-01-28 18:06:29 UTC now serializes
as 2026-01-28 13:06:29 in the timeline endpoint; PATCHing
"2026-01-28T13:06:29" round-trips back to the same UTC value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The legacy RosterUnit.deployed flag drives heartbeat polling and
benched-vs-deployed roster filters. Three workflows ended an
assignment without flipping it, so the outgoing unit kept being
polled and showed up as "deployed" forever:
- swap endpoint (POST /locations/{loc}/swap)
- unassign endpoint (POST /assignments/{aid}/unassign)
- promote-pending endpoint (POST /deployments/pending/{id}/promote)
All three now: close the previous active assignment, break the
outgoing unit's modem pairing (both directions), and set
`deployed = False` on the outgoing unit. Unassign and swap also
clear the modem's back-reference.
The promote-pending path additionally handles the case where the
target location already has an active assignment — that previously
silently created two active assignments at the same location. Now
the old one is closed (assigned_until = pending capture time, status
= completed), the old unit is benched and unpaired, and an
"assignment_swapped" history row is written. Incoming unit gets
`deployed = True` if it was on the bench.
Verified live: triggered a swap via the existing endpoint and saw
the outgoing unit flip True → False while the incoming flipped
False → True. Test mutations rolled back.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each assignment row in the timeline now gets an inline edit (pencil)
that opens a modal with `assigned_at`, `assigned_until`, and notes.
Save calls the existing `PATCH /api/projects/{pid}/assignments/{aid}`;
delete (for misclicks) calls the existing `DELETE`. Open-ended
checkbox clears `assigned_until` and the endpoint flips status back
to "active".
Adds an "+ Add deployment record" button at the top of the timeline
for backfilling historical windows when orphan events sit outside any
assignment. Modal: project → location → assigned_at → assigned_until
(optional open-ended) → notes.
Backend: the `/locations/{loc}/assign` endpoint now accepts an
`assigned_at` form field and a closed-window assignment. The previous
blanket "location already has an active assignment" check is replaced
with same-location overlap detection — closed historical windows that
don't overlap an existing assignment are accepted (which is exactly
the backfill case).
After any save/delete the timeline reloads and the SFM-events list
re-fetches so previously-orphaned events flip to "attributed" when
their timestamp now falls inside an assignment window.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`available-units` and `available-modems` now accept `include_benched=true`
to also return units/modems with `deployed=False`. Default is False so
the existing location-detail swap modal is unchanged. Each row carries
a `deployed` boolean for badge rendering. The Unit Swap wizard fetches
with the flag enabled — exactly the candidates a field tech pulls off
the shelf.
The /swap endpoint now flips the incoming unit (and modem) back to
`deployed=True` when they came in benched, keeping the legacy roster
flag consistent with the active-assignment signal.
Adds the symmetric half of the orphan-pairing fix: when a newly-paired
modem still claims a different seismograph (whose
`deployed_with_modem_id` was never cleared in a past swap), break that
stale back-reference before re-pairing.
`locations-with-assignments` includes `modem.deployed` so the wizard
can badge the current modem in the location card, the "Keep current
modem" choice, the picker rows, and the review screen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>