Reviewed-on: #60
## [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.
---
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>
The photo input had `capture="environment"` which forces mobile
browsers to open the camera and skip the "Photo Library" / "Choose
File" options. Useful when you're literally at the install site,
problematic when you took the photo earlier and want to upload it
now from your gallery.
Removed the attribute. Most mobile browsers now present a chooser
("Take Photo", "Photo Library", "Choose File"). EXIF extraction works
identically either way — the server doesn't care whether the file came
from the camera or the gallery.
Hint copy updated to reflect both options.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Capture entry-point was hidden in /tools cards. Field workflow needs
to be one tap from anywhere, especially on mobile.
Mobile bottom nav: swap Devices → Deploy (slot 3).
Menu / Dashboard / Deploy / Events.
Devices still in the hamburger Menu drawer.
Desktop dashboard header: new orange "Field Deploy" button next to
"Last updated". Only renders at md+ breakpoint (mobile already has it
in the bottom nav).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
UI for the pending-deployment workflow (commits 2 + 3 from the plan,
landed together since commit 1 already shipped the full backend).
New surfaces
- /deploy — mobile-first 3-step wizard. Pick unit → take photo (uses
<input capture="environment"> so it opens the phone camera) → add
optional note + submit. EXIF GPS auto-extracted on the server.
Success page shows the captured coords + links to either "Deploy
another" or "View pending hopper." Whole flow is meant to take
under 90 seconds on site.
- /tools/pending-deployments — the hopper. Filter pills: Awaiting /
Assigned / Cancelled. Each card shows photo thumbnail, unit serial
link, captured-at timestamp, coordinates, operator note, and
status-appropriate actions.
- Classify modal on the hopper: two modes — "Assign to existing
location" (project + location pickers, scoped to vibration_monitoring)
or "Create new location" (with new-or-existing project, plus a
"use captured coords" checkbox that writes the pending row's coords
onto the new location). Calls /pending/{id}/promote on submit.
- Cancel button uses prompt() for the optional reason → POSTs to
/pending/{id}/cancel.
Backend additions
- GET /api/deployments/seismograph-picker — JSON list of non-retired
seismograph units for the /deploy unit picker. Annotates each unit
with has_pending so the picker can flag units that already have a
pending capture waiting.
Discovery
- New "Field Deploy" + "Pending Deployments" cards on /tools.
- Dashboard banner: auto-shows when there are awaiting captures,
polled every 30s. Hides when count drops to 0. Click → /tools/
pending-deployments.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Field-install workflow needs to be fast: arrive on site, snap a photo
of the seismograph in place, leave. Project / location classification
happens later at a desk. This adds the data model + capture endpoint
for that workflow.
Data model
- New PendingDeployment table. Lifecycle: awaiting → assigned (when
promoted to a real UnitAssignment) or → cancelled (operator's
mistake). Photos are filesystem files under data/photos/{unit_id}/
with the filename stored on the row.
- Migration: backend/migrate_add_pending_deployments.py (idempotent).
Endpoints
- POST /api/deployments/capture — multipart upload (unit_id, photo,
optional note). Refuses non-seismographs. Extracts EXIF GPS
(cribbing extract_exif_data from routers/photos.py) and stores
the captured "lat,lon" on the row. Saves the photo under
data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_<uuid8>.<ext>.
Returns the new pending_deployment_id + extracted coords + photo
URL for the client to render confirmation.
- GET /api/deployments/pending — list by status (default awaiting)
- GET /api/deployments/pending/{id} — single row detail
- POST /api/deployments/pending/{id}/promote — classify → create
UnitAssignment. Body accepts two shapes: assign-to-existing-location
OR create-new-location (with new-or-existing project). Sets
status=assigned, resulting_assignment_id, promoted_at.
- POST /api/deployments/pending/{id}/cancel — abandon with optional reason.
All four routes write UnitHistory audit rows
(pending_deployment_captured / _promoted / _cancelled).
Events from a unit with an unclassified pending deployment land in the
unit's "Unattributed" events bucket as usual. Once promoted, the new
UnitAssignment's window retroactively attributes them — same mechanism
the metadata-backfill tool uses.
Seismograph-only for v1. SLM deployments don't follow this pattern
and are tracked elsewhere. Capture refuses non-seismograph unit_ids
with HTTP 400.
UI (commits 2 + 3) lands next.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third view on /tools/deployment-history. Where 'Gantt by Project' has
one row per project showing that project's deployments, 'Gantt by Unit'
inverts it — one row per seismograph, bars colored by the project the
unit was deployed to.
The natural use case: "where has BE11529 been across all my jobs?"
Spotting unit rotation patterns, idle gaps, and concurrent assignments
gets immediate visually.
Service
- deployment_history.get_deployment_history_data() now also returns a
`units` array. Each unit dict carries:
{id, bars[], first_active, assignment_count, any_active}
Each bar has the project_name + project_color baked in so the
renderer can paint by job without a second lookup.
- Units sorted: currently-active first, then by first_active ascending.
UI
- Third tab "Gantt by Unit" added next to Calendar / Gantt by Project.
- Tab switcher refactored to a small registry (_DH_TABS) so adding more
views in the future is a one-line addition.
- URL hash sync now supports #gantt and #byunit; nav buttons preserve
the active tab across month-paging.
- SVG layout: 160px label gutter (smaller than the project Gantt's
220px since unit IDs are short), 32px row height, green dot for
units with at least one active deployment. Unit ID is clickable
→ /unit/{id}; each bar is clickable → /projects/{p}.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Calendar grid (day-cells with project bars) is great for seeing
which projects had activity on a given day, but bad for seeing how
long any single deployment lasted. The Gantt view inverts that —
one row per project, horizontal bars per assignment window — so an
operator can read durations at a glance.
Service layer
- backend/services/deployment_history.py extends each project's
payload with `bars`: a list of {unit_id, location_id, location_name,
start, end, is_active, source} for every UnitAssignment clipped to
the visible 12-month window. Location names are batch-resolved.
Same cost as before since the underlying assignment scan is the
same; just additional data in the response.
Template
- Tab switcher at the top of /tools/deployment-history toggles
between Calendar and Gantt views. URL hash (#gantt) preserves the
active view across month-nav (Prev / Next / Recent buttons within
the Gantt view link to ?...#gantt to stay on the same tab).
- Gantt view is a plain SVG with:
- Left 220px label gutter: project color dot + truncated name,
whole row clickable → opens the project page
- Right area: horizontal time axis with month gridlines + labels,
"today" dashed orange line, one row per project
- One bar per assignment in that row, colored by project, reduced
opacity for closed assignments, blue outline for metadata-
backfilled assignments, white tip on the right edge of active
bars
- Hover any bar → tooltip with unit + location + window
- Alternating row backgrounds for readability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mobile bottom navigation had Menu / Dashboard / Devices / Settings,
which dated back to before the SFM integration. Settings is rarely
needed in the field — Events is the more useful day-to-day mobile
destination now that the SFM event firehose lives there.
New mobile nav: Menu / Dashboard / Devices / Events.
Settings, Projects, Job Planner, Tools, and SFM/SLMM admin pages
all remain accessible via the Menu hamburger which opens the full
sidebar drawer, exactly as they were before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The map sidebar that replaced Upcoming Actions on the project overview
is now also on the deeper Vibration tab — operators get the same
spatial context when they drill into vibration monitoring locations.
Refactor
- New partial templates/partials/projects/location_map.html.
Self-contained: includes the map div + a self-fetch script that
pulls coords from /api/projects/{p}/locations-json on load.
Accepts:
- project_id (required)
- map_height (default "320px")
- location_type ('vibration' | 'sound' | none = all)
- project_dashboard.html: ~150 lines of inline map JS deleted, replaced
with {% include 'partials/projects/location_map.html' %}. Identical
behavior, less duplication.
- projects/detail.html Vibration tab: locations list converted to a
2/3 + 1/3 grid; right column hosts the same map partial filtered
to location_type=vibration with a taller 450px viewport.
Bidirectional hover-highlight (card ↔ pin) works on both surfaces
since the partial registers its own document-level mouseover/mouseout
handlers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverse direction of the existing pin→card flash on the project
overview map. Hovering a location card now enlarges + reddens the
matching pin on the map and opens its tooltip. Mouse-out reverts.
Why hover instead of click: clicking the card title navigates to the
location detail page, so any flash effect would never be visible.
Hover is the right interaction here.
Event delegation on document means cards that appear after htmx
swaps (e.g. after a reorder, remove/restore, or assign-modal close)
still get the behavior without rewiring.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The per-unit Gantt chart on /unit/{id} (Phase 1, v0.11.0) was scoped
to one unit's deployment timeline. This adds the fleet-wide view as
a new entry under /tools.
What it shows
- 12-month calendar grid styled like the Job Planner (4 months per
row, responsive down to single column on mobile).
- Each day cell shows up to 4 colored mini-bars — one per project
that had ≥1 active UnitAssignment that day, color deterministically
hashed from project_id. Days with >4 active projects show "+N".
- KPI strip at the top: project count, distinct unit count, total
assignment count in the window.
- Collapsible project legend: ordered by first-active date (which
matches the deployment-history reading order), each row links to
the project page, shows the assignment count.
Click-a-day side panel
- Click any populated day cell → slide-over panel from the right
- Groups by project, lists every (unit, location) active that day
- Per-deployment: unit link, location link, window dates, active /
closed badge, "auto-backfilled" tag for metadata_backfill source
- Sources from a new GET /api/admin/deployment-history/day endpoint
Navigation
- Prev / Next month buttons shift the 12-month window by one month
- "Recent" button jumps back to default (12 months ending now)
- Default window is 11 months back from current month — operator
sees the recent past on first load, not future emptiness
Files
- backend/services/deployment_history.py — data builder + day-detail
helper. Walks UnitAssignment windows, intersects with the 12-month
range, computes per-project active-day sets.
- backend/routers/deployment_history.py — page route + day-detail JSON
endpoint. Wired into main.py.
- templates/admin/deployment_history.html — page + side-panel
- templates/tools.html — new card linking to the page
Phase 3 (deferred): drag-to-resize bars to retroactively adjust
assignment windows from inside the calendar; per-unit row view
(complement to the project-row view) for "where has unit X been across
all jobs"; horizontal scroll for >12-month windows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The location map's tile-pane (z-index 200), marker-pane (600), and
control-pane (800) outranked the page modals' z-50 because the map's
container didn't establish its own stacking context. Modals opened
over the page rendered BEHIND the map tiles (visible in the Edit
Location, Assign, Remove, etc. modals — anywhere overlapping the
right column).
Fixed with `isolation: isolate` on the map container. That CSS
property forces a new stacking context without needing to rewrite
Leaflet's internal z-indexes, so all the map's panes stay contained
inside the card and z-50 modals correctly render on top.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The right column of every project's overview page now shows a Leaflet
map of its monitoring locations instead of the Upcoming Actions panel.
Operators get an immediate visual of where their locations sit relative
to each other and to nearby sites — much more useful at-a-glance than
the list of pending schedule actions, which sits one tab deeper anyway.
Map behavior
- Pin per active monitoring location with parseable "lat,lon" coords.
Removed locations don't pin (their state is historical).
- Auto-fits bounds to show all pins, with 20px padding. Single-pin
projects center at zoom 14.
- Tooltip on pin hover: location name.
- Click pin → scrolls the matching card into view in the locations list
and flashes an orange ring around it (uses the same data-location-id
the drag-handle code added in commit 52dd6c3).
- scrollWheelZoom disabled to prevent accidental zoom-in when scrolling
the page.
- Locations without coordinates surface as a small inline hint below
the map ("N locations not shown: name1, name2").
- All-coords-missing projects hide the map block entirely and show a
"set coordinates" hint instead.
Discovery preserved: if the project has pending scheduled actions, a
small "{N} upcoming actions →" link appears in the map card header
that switches to the Schedules tab. Operators who care about the
queue still find it instantly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>