Commit Graph

256 Commits

Author SHA1 Message Date
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
serversdown 5ed00bf70e release: v0.13.1 — mic chart defaults to psi (matches PDF)
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>
2026-05-29 19:57:32 +00:00
serversdown 43c804d0c4 event-modal: relabel "Captured at" → "Time received"
"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>
2026-05-29 19:51:58 +00:00
serversdown c1f995b4d3 release: v0.13.0 — SFM integration Phase 1
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>
2026-05-29 19:22:55 +00:00
serversdown 2905a327be admin_events: wire shared event-detail modal into the page
/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>
2026-05-29 01:06:44 +00:00
serversdown 4b2bb9a9c9 event-modal: inline PDF preview + .TXT link + review form
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>
2026-05-29 01:04:15 +00:00
serversdown 1d9fd00cc2 event-modal: port 4-channel Chart.js waveform/histogram panels
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>
2026-05-29 01:01:51 +00:00
serversdown db8d666aa1 settings: add mic_unit_pref for event-report chart
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>
2026-05-29 00:56:41 +00:00
serversdown b2bfa6d268 compose: set TZ=America/New_York on terra-view + sfm services
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>
2026-05-28 05:41:26 +00:00
serversdown d64b9450a1 docs+chore: v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes
CHANGELOG entry for the five commits that landed after the v0.12.0 tag:
two features (Unit Swap wizard at /tools/unit-swap, editable deployment
timeline on /unit/{id}) and two correctness fixes (RosterUnit.deployed
now flips on swap/unassign/promote; deployment timeline now respects
user timezone for both display and edits).  No schema migrations.

README bumped to v0.12.1 with new bullets for the post-v0.12.0 features
and several already-shipped items that were missing from the list (SFM
Event DB Manager, Deployment-History calendar + Gantt tabs, reusable
location-map partial).  backend/main.py VERSION constant bumped too.
2026-05-20 15:34:59 +00:00
serversdown a073b9b06e fix(deployment-timeline): respect user timezone for display and edits
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>
2026-05-18 21:45:52 +00:00
serversdown 502bf5bbeb fix(roster): bench outgoing unit on swap / unassign / deploy-classify
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>
2026-05-18 18:04:09 +00:00
serversdown 472c25372d feat(unit-detail): editable deployment timeline
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>
2026-05-18 06:32:11 +00:00
serversdown 6d37bd759e feat(unit-swap): show benched candidates and clean stale modem pairings
`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>
2026-05-18 04:46:23 +00:00
serversdown 44ab4d8427 feat: test version of unit swap tool. 2026-05-18 01:47:31 +00:00
serversdown aaf9399bb3 chore: update to v0.12.0 2026-05-17 23:06:10 +00:00
serversdown ef6484c350 feat(events): add SFM Event DB Manager for browsing, flagging, and deleting events 2026-05-17 07:57:27 +00:00
serversdown 8cffd7dd5e fix(deploy): allow picking an existing photo, not just camera capture
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>
2026-05-16 04:58:11 +00:00
serversdown ba4cf9e560 feat(deployments): surface /deploy on the mobile nav + dashboard header
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>
2026-05-16 04:56:07 +00:00
serversdown 1af5a94f57 feat(deployments): mobile capture wizard + classify hopper + dashboard banner
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>
2026-05-16 03:45:18 +00:00
serversdown e05f2189c4 feat(deployments): field-capture endpoint for pending deployments (commit 1)
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>
2026-05-16 03:40:24 +00:00
serversdown 7ed94cd8fc feat(tools): add 'Gantt by Unit' tab to deployment history
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>
2026-05-15 23:29:44 +00:00
serversdown 2b8e9168c3 feat(tools): add Gantt view tab to deployment-history page
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>
2026-05-15 22:55:21 +00:00
serversdown 75597ec1c4 feat(mobile): bottom-nav swap Settings → Events
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>
2026-05-15 06:40:18 +00:00
serversdown 4dcfcbdc45 feat(projects): reusable location-map partial + add map to Vibration tab
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>
2026-05-15 06:36:55 +00:00
serversdown 825c7370b8 feat(project-overview): hover location card to highlight its map pin
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>
2026-05-15 06:34:19 +00:00
serversdown 47c65268e3 feat(tools): fleet-wide deployment history calendar (Phase 2)
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>
2026-05-15 06:33:00 +00:00
serversdown ba9cdb4347 chore(release): bump to v0.11.0
Operator-facing polish release on top of v0.10.0's SFM integration:
- Soft-remove monitoring locations (preserves history)
- Per-unit deployment Gantt chart
- Merge consecutive same-location assignments
- Delete assignment for mis-clicks (with safety check)
- Drag-to-reorder location cards (HTML5 native)
- Three-dot kebab menu replaces inline pill buttons
- Event count on vibration cards (instead of "Sessions: 0")
- Project overview location map (replaces Upcoming Actions)
- Stricter backfill location matcher (no false positives on
  boilerplate-shared names like "Area 1" vs "Area 2")
- 3× JSON.stringify quote-collision bug fixes (Remove button,
  backfill typeahead, project-merge dropdown)
- Merge-project modal min-height fix
- Leaflet stacking-context fix (no more map-over-modal)
- delete_assignment column name fix (start_time → started_at)

Migrations added this release:
- migrate_add_location_removed.py
- migrate_add_location_sort_order.py

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:27:38 +00:00
serversdown f063383e61 fix(project-overview): Leaflet map z-index leak covered modals
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>
2026-05-15 06:22:08 +00:00
serversdown 17c988c1ee feat(projects): location map sidebar replaces Upcoming Actions on overview
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>
2026-05-15 05:27:27 +00:00
serversdown d297412d8a feat(locations): show event count on vibration cards instead of sessions
For vibration projects, "Sessions: 0" on every location card was
misleading — monitoring sessions don't exist under the watcher-forward
pipeline.  The relevant number is how many SFM events have been
attributed to the location.

get_project_locations now fans out events_for_location() concurrently
across all vibration locations in the project (via asyncio.gather) and
injects event_count into each item's payload.  Sound locations are
unchanged — they still show session_count.

The template already had the conditional rendering ready from the
previous commit:

    {% if item.event_count is defined and item.location.location_type == 'vibration' %}
        <span><strong>{{ event_count }}</strong> events</span>
    {% else %}
        <span>Sessions: {{ session_count }}</span>
    {% endif %}

so this commit is purely the data-layer change that activates it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 05:25:19 +00:00
serversdown 52dd6c3e32 feat(locations): drag-to-reorder + three-dot kebab menu on cards
Project location cards now reorderable via drag-and-drop, and the
four inline action buttons (Unassign/Edit/Remove/Delete) collapse into
a single three-dot kebab menu — much cleaner card layout, especially
for projects with many locations.

Data
- MonitoringLocation.sort_order: nullable Integer, default 0.
  Migration `migrate_add_location_sort_order.py` adds the column and
  seeds existing rows with sort_order = alphabetical index per project
  (so the post-migration display order matches what operators see
  today — no surprise reordering).
- get_project_locations + locations-json: ORDER BY sort_order, name.
- Location-create: assigns max(sort_order) + 1 so new locations land
  at the END of the list rather than being interleaved alphabetically.

Reorder endpoint
- POST /api/projects/{p}/locations/reorder
  Body: { location_ids: [uuid, uuid, ...] }
  Validates: all ids belong to this project; raises 404 on missing.
  Applies 0-indexed sort_order matching the provided order.

UI changes (templates/partials/projects/location_list.html)
- Active cards get a draggable="true" attribute + native HTML5
  drag/drop handlers.  Drop reorders the DOM immediately, then posts
  the new order to the reorder endpoint.  Drop-zone visual feedback
  (orange ring on hover, opacity on source during drag).
- Six-dot drag handle icon on the left of each active card; whole
  card body is the drag source but the handle is the visual cue.
- Right side: small Assign pill (only shown when unassigned) +
  three-dot kebab menu containing Unassign/Edit/Remove/Delete.
  Click ⋮ to toggle; click outside or Escape to close.  Only one
  menu open at a time.
- Removed locations are NOT draggable (their order is historical) and
  keep their existing Restore button visible.

The card also shows "{N} events" instead of "Sessions: N" when the
location_type is vibration AND the backend passes event_count in
the payload — which lands in commit 2 of this redesign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 05:23:25 +00:00
serversdown 295f9637b3 fix(merge-project): dropdown unclickable + modal too short to show it
Two bugs in the project-merge modal:

1. Dropdown options had the same JSON.stringify quote-collision in
   their inline onclick that broke the location Remove button and the
   metadata-backfill typeahead earlier this week:

     onclick="onMergePickTarget('${id}', ${JSON.stringify(m.name)})"

   For 'I-80 Area 1' that renders as onclick="...(\"I-80 Area 1\")" —
   the inner double quotes terminate the onclick attribute early,
   and the browser never binds the click handler.  Operator clicked
   items in the dropdown and nothing happened.

   Fixed via data-target-id / data-target-name attributes and a
   _mergePickFromButton(btn) trampoline.

2. Modal body had `flex-1 overflow-y-auto` with no min-height, so the
   container shrunk tight around the input.  When the typeahead
   dropdown appeared below the input it got clipped by the body's
   overflow and the operator had to scroll inside the modal to see
   the options.

   Fixed by adding min-height: 480px to the modal container + min-h-
   [320px] on the body so there's always room for the dropdown + the
   preview pane that appears below after a target is picked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:54:33 +00:00
serversdown ad55d4ca09 fix(backfill): location matching over-confident on boilerplate-shared names
rapidfuzz.fuzz.WRatio inflates scores when two strings share substring
tokens, even when the shared tokens are common boilerplate.  For
project names this is desirable (catches typos like '1-80' vs 'I-80')
but for location names it produces obvious false positives:

  'Area 2 - Brookville Dam - Loc 2 East'
        vs
  'Area 1 - Loc 1 - 87 Jenks'              → WRatio 85.5 (above 0.80 fuzzy threshold)

These share only 'area' + 'loc' + a digit but score 85%+ because WRatio
weights partial-substring overlap heavily.  Operator reported the
backfill tool suggesting completely unrelated locations as 86% matches.

Fix: introduce `location_similarity()` — token_set_ratio + multi-digit
mismatch penalty.  Used for location matching everywhere; WRatio stays
as the scorer for project names where its leniency is correct.

The multi-digit penalty (-0.30) triggers when both strings contain 2+-
digit numbers and none overlap.  Catches the harder "same project,
different address identifier" case:

  'Area 1 - Loc 2 - 68 Jenks' vs 'Area 1 - Loc 1 - 87 Jenks'
  token_set_ratio = 0.91 (would still match without penalty)
  multi-digit tokens {68} and {87} disjoint → -0.30 → 0.61 (rejected)

Single-digit tokens ('Loc 1', 'Area 2') are excluded from the penalty
because they're often coincidentally shared.

Updated:
- backend/services/metadata_backfill.py: new location_similarity()
  function; _find_best_match() gains a `kind` parameter that selects
  scorer; cluster-match call site passes kind='location'
- backend/routers/metadata_backfill.py: locations_search endpoint
  (the typeahead dropdown's data source) uses location_similarity
  instead of similarity for the same reason

Verified all six test cases land correctly:
- user-reported false positive:         0.85 → 0.59 (rejected)
- '87 Jenks' vs '68 Jenks':            0.90 → 0.61 (rejected)
- NRL-01 vs NRL-02:                    0.83 → 0.53 (rejected)
- 'Loc 2 - 735 Bunola' vs 'Loc 2 735 Bunola Rd':  1.00 (still matches)
- punctuation-only difference:          1.00 (still matches)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:10:48 +00:00
serversdown ba1f28ee53 fix(backfill): typeahead picks broken by JSON.stringify quote collision in onclick
The inline onclick on each typeahead dropdown item was:

  onclick="onTypeaheadPick(event, 'cid', 'location', 'loc-id', ${JSON.stringify(m.name)})"

For any name with spaces/punctuation (i.e. every real location name like
"Area 1 - Loc 1 - 87 Jenks"), JSON.stringify emits double quotes around
the value, which collide with the onclick attribute's own double quotes
and terminate the attribute early.  The dropdown rendered fine via
.innerHTML, but the browser's HTML parser saw a broken attribute and
never bound the click handler — clicks on dropdown items silently did
nothing.

Same pattern that broke the location Remove button yesterday.  Same fix:
move args into data-* attributes and dispatch through a tiny trampoline
that reads from this.dataset.  Robust against any character in
project/location names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 03:59:38 +00:00
serversdown c48c6e5bca fix(assignments): delete_assignment used wrong column name on MonitoringSession
The safety check that refuses to delete assignments with real recording
history referenced MonitoringSession.start_time, but the actual column
is MonitoringSession.started_at.  Every DELETE call to /assignments/{id}
crashed with AttributeError before doing anything.

Now uses started_at correctly.  Verified end-to-end on dev.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 02:28:52 +00:00